Skip to main content

iris_chat_core/
lib.rs

1mod actions;
2mod core;
3pub mod desktop_nearby;
4pub mod image_proxy;
5pub mod local_relay;
6pub mod perflog;
7mod qr;
8mod state;
9mod updates;
10
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, RwLock};
13use std::thread;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15use std::{panic, panic::AssertUnwindSafe};
16
17use flume::{Receiver, Sender};
18
19pub use actions::AppAction;
20pub use qr::*;
21pub use state::*;
22pub use updates::*;
23
24use crate::core::AppCore;
25
26uniffi::setup_scaffolding!();
27
28#[uniffi::export(callback_interface)]
29pub trait AppReconciler: Send + Sync + 'static {
30    fn reconcile(&self, update: AppUpdate);
31}
32
33#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
34pub struct DesktopNearbyPeerSnapshot {
35    pub id: String,
36    pub name: String,
37    pub owner_pubkey_hex: Option<String>,
38    pub picture_url: Option<String>,
39    pub profile_event_id: Option<String>,
40    pub last_seen_secs: u64,
41}
42
43#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
44pub struct DesktopNearbySnapshot {
45    pub visible: bool,
46    pub status: String,
47    pub peers: Vec<DesktopNearbyPeerSnapshot>,
48}
49
50#[uniffi::export(callback_interface)]
51pub trait DesktopNearbyObserver: Send + Sync + 'static {
52    fn desktop_nearby_changed(&self, snapshot: DesktopNearbySnapshot);
53}
54
55#[derive(uniffi::Object)]
56pub struct FfiApp {
57    core_tx: Sender<CoreMsg>,
58    update_rx: Receiver<AppUpdate>,
59    listening: AtomicBool,
60    shared_state: Arc<RwLock<AppState>>,
61}
62
63#[derive(uniffi::Object)]
64pub struct FfiDesktopNearby {
65    service: Arc<desktop_nearby::DesktopNearbyService>,
66}
67
68#[uniffi::export]
69impl FfiApp {
70    #[uniffi::constructor]
71    pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
72        let (update_tx, update_rx) = flume::unbounded();
73        let (core_tx, core_rx) = flume::unbounded();
74        let shared_state = Arc::new(RwLock::new(AppState::empty()));
75
76        let core_tx_for_thread = core_tx.clone();
77        let shared_for_thread = shared_state.clone();
78        thread::spawn(move || {
79            let mut core = AppCore::new(update_tx, core_tx_for_thread, data_dir, shared_for_thread);
80            // Drain whatever is already queued and process it as one batch so
81            // a flurry of relay events + user actions produces a single UI
82            // update instead of N. Without this, tapping a chat while a
83            // relay backlog drains can take seconds because OpenChat sits
84            // behind every queued event and the UI recomposes between each.
85            while let Ok(first) = core_rx.recv() {
86                let mut batch = Vec::with_capacity(8);
87                batch.push(first);
88                while let Ok(next) = core_rx.try_recv() {
89                    batch.push(next);
90                }
91                let batch_size = batch.len();
92                let t0 = crate::perflog::now_ms();
93                crate::perflog!("core.batch.start size={batch_size}");
94                if !handle_core_batch_responsive(&mut core, batch) {
95                    break;
96                }
97                crate::perflog!(
98                    "core.batch.end size={batch_size} elapsed_ms={}",
99                    crate::perflog::now_ms().saturating_sub(t0)
100                );
101            }
102        });
103
104        Arc::new(Self {
105            core_tx,
106            update_rx,
107            listening: AtomicBool::new(false),
108            shared_state,
109        })
110    }
111
112    pub fn state(&self) -> AppState {
113        match self.shared_state.read() {
114            Ok(slot) => slot.clone(),
115            Err(poison) => poison.into_inner().clone(),
116        }
117    }
118
119    pub fn dispatch(&self, action: AppAction) {
120        crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
121        let _ = self.core_tx.send(CoreMsg::Action(action));
122    }
123
124    pub fn ingest_nearby_event_json(&self, event_json: String) -> bool {
125        let event = match serde_json::from_str::<nostr_sdk::prelude::Event>(&event_json) {
126            Ok(event) => event,
127            Err(_) => return false,
128        };
129        if event.verify().is_err() {
130            return false;
131        }
132        self.core_tx
133            .send(CoreMsg::Internal(Box::new(InternalEvent::NearbyEvent(
134                event,
135            ))))
136            .is_ok()
137    }
138
139    pub fn build_nearby_presence_event_json(
140        &self,
141        peer_id: String,
142        my_nonce: String,
143        their_nonce: String,
144        profile_event_id: String,
145    ) -> String {
146        let (reply_tx, reply_rx) = flume::bounded(1);
147        if self
148            .core_tx
149            .send(CoreMsg::BuildNearbyPresenceEvent {
150                peer_id,
151                my_nonce,
152                their_nonce,
153                profile_event_id,
154                reply_tx,
155            })
156            .is_err()
157        {
158            return String::new();
159        }
160        reply_rx
161            .recv_timeout(Duration::from_secs(2))
162            .unwrap_or_default()
163    }
164
165    pub fn verify_nearby_presence_event_json(
166        &self,
167        event_json: String,
168        peer_id: String,
169        my_nonce: String,
170        their_nonce: String,
171    ) -> String {
172        verify_nearby_presence_event_json(&event_json, &peer_id, &my_nonce, &their_nonce)
173    }
174
175    pub fn nearby_encode_frame(&self, envelope_json: String) -> Vec<u8> {
176        nostr_double_ratchet::encode_nearby_frame_json(&envelope_json).unwrap_or_default()
177    }
178
179    pub fn nearby_decode_frame(&self, frame: Vec<u8>) -> String {
180        nostr_double_ratchet::decode_nearby_frame_json(&frame).unwrap_or_default()
181    }
182
183    pub fn nearby_frame_body_len_from_header(&self, header: Vec<u8>) -> i32 {
184        nostr_double_ratchet::nearby_frame_body_len_from_header(&header)
185            .and_then(|len| i32::try_from(len).ok())
186            .unwrap_or(-1)
187    }
188
189    pub fn export_support_bundle_json(&self) -> String {
190        let (reply_tx, reply_rx) = flume::bounded(1);
191        if self
192            .core_tx
193            .send(CoreMsg::ExportSupportBundle(reply_tx))
194            .is_err()
195        {
196            return "{}".to_string();
197        }
198        reply_rx.recv().unwrap_or_else(|_| "{}".to_string())
199    }
200
201    pub fn shutdown(&self) {
202        let (reply_tx, reply_rx) = flume::bounded(1);
203        if self
204            .core_tx
205            .send(CoreMsg::Shutdown(Some(reply_tx)))
206            .is_err()
207        {
208            return;
209        }
210        let _ = reply_rx.recv();
211    }
212
213    pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
214        if self
215            .listening
216            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
217            .is_err()
218        {
219            return;
220        }
221
222        let update_rx = self.update_rx.clone();
223        thread::spawn(move || {
224            // Drain queued updates and deliver the latest FullState only.
225            // The shell side already discards FullStates with stale `rev`,
226            // but the JNI marshal of an AppState is itself ~20-30 ms and
227            // each push triggers a full Compose recomposition (~400 ms on
228            // Android debug). When the core emits a tight burst of 3-4
229            // updates (OpenChat → SyncComplete → FetchCatchUpEvents → …)
230            // the UI keeps re-rendering for seconds even though only the
231            // final state mattered.
232            //
233            // PersistAccountBundle is a side-effect (key persistence), not
234            // a UI update, so we never collapse those — every one must run.
235            while let Ok(first) = update_rx.recv() {
236                let mut latest_full_state: Option<AppUpdate> = None;
237                let mut sidecar: Vec<AppUpdate> = Vec::new();
238                let process = |update: AppUpdate,
239                               latest: &mut Option<AppUpdate>,
240                               side: &mut Vec<AppUpdate>| match update
241                {
242                    full @ AppUpdate::FullState(_) => *latest = Some(full),
243                    other => side.push(other),
244                };
245                process(first, &mut latest_full_state, &mut sidecar);
246                while let Ok(next) = update_rx.try_recv() {
247                    process(next, &mut latest_full_state, &mut sidecar);
248                }
249                for update in sidecar.into_iter().chain(latest_full_state) {
250                    let kind = match &update {
251                        AppUpdate::FullState(_) => "FullState",
252                        AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
253                        AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
254                    };
255                    let t0 = crate::perflog::now_ms();
256                    crate::perflog!("reconcile.start kind={kind}");
257                    if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
258                        .is_err()
259                    {
260                        crate::perflog!("reconcile.failed kind={kind}");
261                        continue;
262                    }
263                    crate::perflog!(
264                        "reconcile.end kind={kind} elapsed_ms={}",
265                        crate::perflog::now_ms().saturating_sub(t0)
266                    );
267                }
268            }
269        });
270    }
271}
272
273#[uniffi::export]
274impl FfiDesktopNearby {
275    #[uniffi::constructor]
276    pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
277        Arc::new(Self {
278            service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
279        })
280    }
281
282    pub fn start(&self, local_name: String) {
283        self.service.start(local_name);
284    }
285
286    pub fn stop(&self) {
287        self.service.stop();
288    }
289
290    pub fn snapshot(&self) -> DesktopNearbySnapshot {
291        self.service.snapshot()
292    }
293
294    pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
295        self.service
296            .publish(event_id, kind, created_at_secs, event_json);
297    }
298}
299
300fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
301    if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
302        return core.handle_messages(messages);
303    }
304
305    let mut foreground = Vec::new();
306    let mut background = Vec::new();
307    for message in messages {
308        if is_foreground_core_msg(&message) {
309            foreground.push(message);
310        } else {
311            background.push(message);
312        }
313    }
314
315    for message in foreground {
316        if !core.handle_message(message) {
317            return false;
318        }
319    }
320    background.is_empty() || core.handle_messages(background)
321}
322
323fn is_foreground_core_msg(message: &CoreMsg) -> bool {
324    !matches!(message, CoreMsg::Internal(_))
325}
326
327fn verify_nearby_presence_event_json(
328    event_json: &str,
329    peer_id: &str,
330    my_nonce: &str,
331    their_nonce: &str,
332) -> String {
333    let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
334        return String::new();
335    };
336    if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
337        return String::new();
338    }
339    let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
340        return String::new();
341    };
342    let get = |key: &str| {
343        content
344            .get(key)
345            .and_then(|value| value.as_str())
346            .unwrap_or("")
347    };
348    let transport = get("transport");
349    if get("protocol") != "iris-nearby-v1"
350        || !(transport == "ble" || transport == "nearby" || transport == "lan")
351        || get("peer_id") != peer_id.trim()
352        || get("my_nonce") != their_nonce.trim()
353        || get("their_nonce") != my_nonce.trim()
354    {
355        return String::new();
356    }
357
358    let now = SystemTime::now()
359        .duration_since(UNIX_EPOCH)
360        .unwrap_or_default()
361        .as_secs();
362    let expires_at = content
363        .get("expires_at")
364        .and_then(|value| value.as_u64())
365        .unwrap_or(0);
366    let created_at = event.created_at.as_secs();
367    if expires_at < now
368        || expires_at > now.saturating_add(300)
369        || created_at.saturating_add(300) < now
370        || created_at > now.saturating_add(300)
371    {
372        return String::new();
373    }
374
375    let profile_event_id = get("profile_event_id");
376    let profile_event_id = if profile_event_id.len() == 64 {
377        profile_event_id
378    } else {
379        ""
380    };
381    serde_json::json!({
382        "owner_pubkey_hex": event.pubkey.to_hex(),
383        "profile_event_id": profile_event_id,
384    })
385    .to_string()
386}
387
388impl Drop for FfiApp {
389    fn drop(&mut self) {
390        let _ = self.core_tx.send(CoreMsg::Shutdown(None));
391    }
392}
393
394#[uniffi::export]
395pub fn normalize_peer_input(input: String) -> String {
396    crate::core::normalize_peer_input_for_display(&input)
397}
398
399#[uniffi::export]
400pub fn is_valid_peer_input(input: String) -> bool {
401    crate::core::parse_peer_input(&input).is_ok()
402}
403
404/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its
405/// canonical lowercase-hex form. The empty string is returned when the
406/// input can't be parsed as a public key — callers expecting hex
407/// downstream can short-circuit on that.
408#[uniffi::export]
409pub fn peer_input_to_hex(input: String) -> String {
410    let normalized = crate::core::normalize_peer_input_for_display(&input);
411    match nostr::PublicKey::parse(&normalized) {
412        Ok(pubkey) => pubkey.to_hex(),
413        Err(_) => String::new(),
414    }
415}
416
417/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its npub form.
418/// Returns the original string when it can't be parsed as a public key.
419#[uniffi::export]
420pub fn peer_input_to_npub(input: String) -> String {
421    use nostr::nips::nip19::ToBech32;
422    let normalized = crate::core::normalize_peer_input_for_display(&input);
423    match nostr::PublicKey::parse(&normalized) {
424        Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
425        Err(_) => normalized,
426    }
427}
428
429#[uniffi::export]
430pub fn build_summary() -> String {
431    crate::core::build_summary()
432}
433
434#[uniffi::export]
435pub fn relay_set_id() -> String {
436    crate::core::relay_set_id().to_string()
437}
438
439#[uniffi::export]
440pub fn proxied_image_url(
441    original_src: String,
442    preferences: PreferencesSnapshot,
443    width: Option<u32>,
444    height: Option<u32>,
445    square: bool,
446) -> String {
447    image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
448}
449
450#[uniffi::export]
451pub fn is_trusted_test_build() -> bool {
452    crate::core::trusted_test_build_flag()
453}
454
455#[uniffi::export]
456pub fn resolve_mobile_push_notification_payload(
457    raw_payload_json: String,
458) -> MobilePushNotificationResolution {
459    crate::core::resolve_mobile_push_notification(raw_payload_json)
460}
461
462/// Decrypt a notification payload against the persisted double-ratchet
463/// state under `data_dir`. Use from the FCM service (Android) or
464/// Notification Service Extension (iOS) where there's no live `FfiApp`.
465/// Falls back to the generic resolver when keys, payload, or storage
466/// are unavailable so the user still gets *some* notification.
467#[uniffi::export]
468pub fn decrypt_mobile_push_notification_payload(
469    data_dir: String,
470    owner_pubkey_hex: String,
471    device_nsec: String,
472    raw_payload_json: String,
473) -> MobilePushNotificationResolution {
474    crate::core::decrypt_mobile_push_notification(
475        data_dir,
476        owner_pubkey_hex,
477        device_nsec,
478        raw_payload_json,
479    )
480}
481
482#[uniffi::export]
483pub fn resolve_mobile_push_subscription_server_url(
484    platform_key: String,
485    is_release: bool,
486    override_url: Option<String>,
487) -> String {
488    crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url)
489}
490
491#[uniffi::export]
492pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
493    crate::core::mobile_push_stored_subscription_id_key(platform_key)
494}
495
496#[uniffi::export]
497pub fn build_mobile_push_list_subscriptions_request(
498    owner_nsec: String,
499    platform_key: String,
500    is_release: bool,
501    server_url_override: Option<String>,
502) -> Option<MobilePushSubscriptionRequest> {
503    crate::core::build_mobile_push_list_subscriptions_request(
504        owner_nsec,
505        platform_key,
506        is_release,
507        server_url_override,
508    )
509}
510
511#[uniffi::export]
512#[allow(clippy::too_many_arguments)]
513pub fn build_mobile_push_create_subscription_request(
514    owner_nsec: String,
515    platform_key: String,
516    push_token: String,
517    apns_topic: Option<String>,
518    message_author_pubkeys: Vec<String>,
519    invite_response_pubkeys: Vec<String>,
520    is_release: bool,
521    server_url_override: Option<String>,
522) -> Option<MobilePushSubscriptionRequest> {
523    crate::core::build_mobile_push_create_subscription_request(
524        owner_nsec,
525        platform_key,
526        push_token,
527        apns_topic,
528        message_author_pubkeys,
529        invite_response_pubkeys,
530        is_release,
531        server_url_override,
532    )
533}
534
535#[uniffi::export]
536#[allow(clippy::too_many_arguments)]
537pub fn build_mobile_push_update_subscription_request(
538    owner_nsec: String,
539    subscription_id: String,
540    platform_key: String,
541    push_token: String,
542    apns_topic: Option<String>,
543    message_author_pubkeys: Vec<String>,
544    invite_response_pubkeys: Vec<String>,
545    is_release: bool,
546    server_url_override: Option<String>,
547) -> Option<MobilePushSubscriptionRequest> {
548    crate::core::build_mobile_push_update_subscription_request(
549        owner_nsec,
550        subscription_id,
551        platform_key,
552        push_token,
553        apns_topic,
554        message_author_pubkeys,
555        invite_response_pubkeys,
556        is_release,
557        server_url_override,
558    )
559}
560
561#[uniffi::export]
562pub fn build_mobile_push_delete_subscription_request(
563    owner_nsec: String,
564    subscription_id: String,
565    platform_key: String,
566    is_release: bool,
567    server_url_override: Option<String>,
568) -> Option<MobilePushSubscriptionRequest> {
569    crate::core::build_mobile_push_delete_subscription_request(
570        owner_nsec,
571        subscription_id,
572        platform_key,
573        is_release,
574        server_url_override,
575    )
576}