umsh_mobile_core/
mobile_mesh.rs

1//! Rust-owned mobile mesh session.
2//!
3//! The platform adapter moves complete raw frames between this object and a
4//! ULCP transport. It never constructs MAC commands, advances counters,
5//! or correlates ping replies.
6
7use core::{
8    cell::RefCell,
9    marker::PhantomData,
10    pin::Pin,
11    task::{Context, Poll},
12};
13use std::{
14    collections::{BTreeMap, BTreeSet, VecDeque},
15    fmt,
16    rc::Rc,
17    sync::{
18        Arc, Mutex,
19        atomic::{AtomicBool, AtomicU64, Ordering},
20        mpsc as std_mpsc,
21    },
22    time::Duration,
23};
24
25use embedded_hal_async::delay::DelayNs;
26use tokio::sync::{mpsc, oneshot};
27use umsh_core::{ChannelKey, ChannelTag, NodeHint, PayloadType, PublicKey};
28use umsh_crypto::{
29    CryptoEngine, NodeIdentity,
30    software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
31};
32use umsh_hal::{Clock, CounterStore, KeyValueStore, Radio, RxInfo, Snr, TxError, TxOptions};
33use umsh_mac::{Mac, MacHandle, OperatingPolicy, RepeaterConfig, SendOptions};
34use umsh_node::{
35    Host, LocalNode, MacBackend, NodeCapabilities, NodeIdentityPayload, NodeIdentityProfile,
36    NodeRole, PacketFamily, SendProgressTicket, Transport,
37    location::{MAX_PRECISION, NodeLocation},
38};
39use umsh_sync::AsyncRefCell;
40use umsh_text::engine::{ArchiveResult, DeliveryState, Destination};
41use umsh_text::model::{ConversationKey, SenderScope};
42use umsh_text::validate::{DeliveryPath, Envelope};
43
44use crate::mobile_chat::{
45    ChannelRegistry, MobileChatArchiveLookupRecord, MobileChatArchiveResultKind,
46    MobileChatCheckpointRecord, MobileChatComposeBatchRecord, MobileChatDeliveryRecord,
47    MobileChatDirection, MobileChatMutationKind, MobileChatMutationRecord, MobileChatOriginalRef,
48    MobileChatPresence, MobileChatRegardingRef, MobileChatRxMetadataRecord,
49    MobileChatSenderResolutionRecord, MobileChatState,
50};
51use crate::{MobileCounterStore, MobileError, MobileIdentity};
52
53const MAX_FRAME_SIZE: usize = 256;
54const DEFAULT_FRAME_TIME_MS: u32 = 800;
55/// Flood-hop budget on a beacon. A beacon exists to publish a path, so it
56/// has to travel far enough for there to be a path worth publishing.
57const BEACON_FLOOD_HOPS: u8 = 5;
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Error)]
60pub enum MobileMeshError {
61    InvalidPeer,
62    SessionUnavailable,
63    OperationInProgress,
64    CounterPersistenceFailed,
65    SendFailed,
66    ChatComposeFailed,
67    ChatBatchMissing,
68    /// A channel key was not exactly 32 octets.
69    InvalidChannelKey,
70    /// The MAC's channel table is full.
71    ChannelCapacity,
72    /// The conversation address was malformed, or named a channel this
73    /// session holds no key for.
74    UnknownConversation,
75    /// A shared location did not name a place: a non-finite or
76    /// out-of-range coordinate, or a precision the cell code cannot
77    /// carry.
78    InvalidLocation,
79}
80
81impl fmt::Display for MobileMeshError {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        formatter.write_str(match self {
84            Self::InvalidPeer => "MESH_INVALID_PEER",
85            Self::SessionUnavailable => "MESH_SESSION_UNAVAILABLE",
86            Self::OperationInProgress => "MESH_OPERATION_IN_PROGRESS",
87            Self::CounterPersistenceFailed => "MESH_COUNTER_PERSISTENCE_FAILED",
88            Self::SendFailed => "MESH_SEND_FAILED",
89            Self::ChatComposeFailed => "MESH_CHAT_COMPOSE_FAILED",
90            Self::ChatBatchMissing => "MESH_CHAT_BATCH_MISSING",
91            Self::InvalidChannelKey => "MESH_INVALID_CHANNEL_KEY",
92            Self::ChannelCapacity => "MESH_CHANNEL_CAPACITY",
93            Self::UnknownConversation => "MESH_UNKNOWN_CONVERSATION",
94            Self::InvalidLocation => "MESH_INVALID_LOCATION",
95        })
96    }
97}
98
99impl std::error::Error for MobileMeshError {}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
102pub enum MobileMeshPingOutcome {
103    Reply,
104    TimedOut,
105    Failed,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
109pub struct MobileMeshPingEventRecord {
110    pub operation_id: u64,
111    pub outcome: MobileMeshPingOutcome,
112    pub round_trip_milliseconds: Option<u64>,
113    /// Total radio links traversed by the response, when the wire metadata can
114    /// determine it. A direct response is one hop.
115    pub hop_count: Option<u8>,
116    /// Authenticated intermediate-router hints, in source-to-destination order.
117    /// The two endpoints are not included.
118    pub route_hints: Vec<Vec<u8>>,
119    /// Signal measurements for the final radio hop into this device.
120    pub rssi_dbm: Option<i16>,
121    pub snr_centibels: Option<i16>,
122    pub lqi: Option<u8>,
123}
124
125/// How the MAC will address the next frame sent to a peer.
126#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
127pub enum MobileMeshRouteKind {
128    /// Nothing has been learned for this peer, so the next send falls back to
129    /// the default delivery mode. Also reported for a peer the MAC does not
130    /// have registered at all.
131    Unknown,
132    /// The peer answered without any intermediate router.
133    Direct,
134    /// An explicit source route, learned by reversing an inbound trace route.
135    Source,
136    /// Flood delivery with a learned hop budget.
137    Flood,
138}
139
140/// The route the MAC currently has cached for one peer.
141#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
142pub struct MobileMeshRouteRecord {
143    pub kind: MobileMeshRouteKind,
144    /// Router hints in source-to-destination order. Populated for `Source`
145    /// routes only; the two endpoints are not included.
146    pub hints: Vec<Vec<u8>>,
147    /// Hop budget carried by a `Flood` route.
148    pub flood_hops: Option<u8>,
149    /// Two-octet region codes learned with a `Flood` route.
150    pub flood_regions: Vec<Vec<u8>>,
151}
152
153impl MobileMeshRouteRecord {
154    fn unknown() -> Self {
155        Self {
156            kind: MobileMeshRouteKind::Unknown,
157            hints: Vec::new(),
158            flood_hops: None,
159            flood_regions: Vec::new(),
160        }
161    }
162}
163
164impl From<Option<umsh_mac::CachedRoute>> for MobileMeshRouteRecord {
165    fn from(route: Option<umsh_mac::CachedRoute>) -> Self {
166        match route {
167            None => Self::unknown(),
168            Some(umsh_mac::CachedRoute::Direct) => Self {
169                kind: MobileMeshRouteKind::Direct,
170                ..Self::unknown()
171            },
172            Some(umsh_mac::CachedRoute::Source(hops)) => Self {
173                kind: MobileMeshRouteKind::Source,
174                hints: hops.iter().map(|hop| hop.0.to_vec()).collect(),
175                ..Self::unknown()
176            },
177            Some(umsh_mac::CachedRoute::Flood { hops, regions }) => Self {
178                kind: MobileMeshRouteKind::Flood,
179                flood_hops: Some(hops),
180                flood_regions: regions.iter().map(|region| region.to_vec()).collect(),
181                ..Self::unknown()
182            },
183        }
184    }
185}
186
187/// A node-identity bundle received over the mesh, either as a broadcast
188/// advertisement or as the reply to an Identity Request.
189///
190/// Only frames whose sender the MAC could name are surfaced. How the claims
191/// may be trusted depends on how they arrived, which is what
192/// `source_authenticated` reports.
193#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
194pub struct MobileMeshAdvertisementRecord {
195    /// Canonical Base58 address of the claimed sender.
196    pub peer_address: String,
197    /// Raw node-identity payload bytes (without the payload-type byte),
198    /// decodable with `decode_node_identity`.
199    pub payload: Vec<u8>,
200    /// Whether the MAC authenticated the sender of the frame that carried
201    /// this bundle.
202    ///
203    /// A unicast Identity Request reply is authenticated by its MIC, so it
204    /// carries no detached signature and decodes as `Unsigned` — it is
205    /// nonetheless trustworthy, and the platform must accept it. A broadcast
206    /// advertisement has no MIC, so it is `false` and the platform must
207    /// require a `Valid` embedded signature before trusting any claim.
208    pub source_authenticated: bool,
209}
210
211/// The position this phone is willing to put in its identity.
212///
213/// Precision is the disclosure decision: the wire format carries a cell,
214/// not a point, and the coordinate is reduced to that cell before it goes
215/// anywhere. The platform hands over its best reading and the chosen cell
216/// size; the truncation happens here, on this side of every send.
217#[derive(Clone, Copy, Debug, PartialEq, uniffi::Record)]
218pub struct MobileMeshSharedLocationRecord {
219    pub latitude_degrees: f64,
220    pub longitude_degrees: f64,
221    /// Cell-code precision in bytes, 1–7. `ulcp_location_cell_meters`
222    /// names the cell size each buys.
223    pub precision_bytes: u8,
224}
225
226/// Evidence that a peer was on the air, emitted for every accepted frame
227/// regardless of what it carried.
228///
229/// A beacon is the case this exists for: it has no payload, so it produces no
230/// advertisement, no message, and no ping reply, yet it is the cheapest
231/// possible proof that a node is still reachable. Presence is not a claim
232/// about content, so nothing here needs to be authenticated to be useful —
233/// it says only that a frame naming this sender was accepted.
234#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
235pub struct MobileMeshPeerHeardRecord {
236    /// Canonical Base58 address of the sender, when the frame named a full
237    /// public key or the MAC could resolve one. `None` for a hint-only
238    /// source, which the platform may still resolve against its own peer
239    /// list — see `node_hint`.
240    pub peer_address: Option<String>,
241    /// The 3-byte source node hint, when the frame carried one. Hints are
242    /// ambiguous by design: a platform matching one against saved peers must
243    /// treat a multi-way match as no match at all.
244    pub node_hint: Option<Vec<u8>>,
245    /// Whether the MAC authenticated this frame's sender. A beacon is an
246    /// unauthenticated broadcast, so this is usually `false`; it is reported
247    /// so the platform can tell "a frame claiming to be from X" from "a frame
248    /// proven to be from X".
249    pub source_authenticated: bool,
250}
251
252/// Platform-side listener invoked when `poll_update` has new data waiting.
253///
254/// Called on the worker thread; implementations must only schedule a drain
255/// on their own executor and return. Notifications are coalesced: at most
256/// one call fires per pending-to-drained cycle, so a burst of protocol
257/// activity costs one crossing, and the platform needs no polling cadence.
258#[uniffi::export(with_foreign)]
259pub trait MobileMeshWakeListener: Send + Sync {
260    fn on_update_pending(&self);
261}
262
263/// Coalescing wake flag shared between the worker's producer channels and
264/// `poll_update`. `notify` fires the listener only on the false-to-true
265/// transition; `drained` re-arms it.
266struct WakeSignal {
267    pending: AtomicBool,
268    listener: Mutex<Option<Arc<dyn MobileMeshWakeListener>>>,
269}
270
271impl WakeSignal {
272    fn new() -> Self {
273        Self {
274            pending: AtomicBool::new(false),
275            listener: Mutex::new(None),
276        }
277    }
278
279    fn notify(&self) {
280        if self.pending.swap(true, Ordering::AcqRel) {
281            return;
282        }
283        let listener = self
284            .listener
285            .lock()
286            .ok()
287            .and_then(|slot| slot.as_ref().cloned());
288        if let Some(listener) = listener {
289            listener.on_update_pending();
290        }
291    }
292
293    fn drained(&self) {
294        self.pending.store(false, Ordering::Release);
295    }
296
297    fn set_listener(&self, listener: Option<Arc<dyn MobileMeshWakeListener>>) {
298        let already_pending = {
299            let Ok(mut slot) = self.listener.lock() else {
300                return;
301            };
302            *slot = listener.clone();
303            self.pending.load(Ordering::Acquire)
304        };
305        // Data enqueued before registration must not wait for the next
306        // protocol event to surface.
307        if already_pending && let Some(listener) = listener {
308            listener.on_update_pending();
309        }
310    }
311}
312
313/// A producer channel endpoint that arms the wake signal on every enqueue.
314struct NotifyingSender<T> {
315    tx: std_mpsc::Sender<T>,
316    wake: Arc<WakeSignal>,
317}
318
319impl<T> Clone for NotifyingSender<T> {
320    fn clone(&self) -> Self {
321        Self {
322            tx: self.tx.clone(),
323            wake: self.wake.clone(),
324        }
325    }
326}
327
328impl<T> NotifyingSender<T> {
329    fn send(&self, value: T) -> Result<(), std_mpsc::SendError<T>> {
330        self.tx.send(value)?;
331        self.wake.notify();
332        Ok(())
333    }
334}
335
336#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
337pub struct MobileMeshSessionUpdateRecord {
338    /// Complete raw UMSH frames ready for the ULCP PHY transport. Each
339    /// frame must be completed after the device reports the physical radio
340    /// result; queue acceptance is not transmit completion.
341    pub outbound_frames: Vec<MobileMeshOutboundFrameRecord>,
342    pub ping_events: Vec<MobileMeshPingEventRecord>,
343    pub advertisement_events: Vec<MobileMeshAdvertisementRecord>,
344    pub peer_heard_events: Vec<MobileMeshPeerHeardRecord>,
345    /// Chat effects remain in the facade until Swift durably applies them and
346    /// acknowledges this batch. Repeated polls may return the same batch.
347    pub chat_batch_id: Option<u64>,
348    pub chat_mutations: Vec<MobileChatMutationRecord>,
349    pub chat_deliveries: Vec<MobileChatDeliveryRecord>,
350    pub chat_archive_lookups: Vec<MobileChatArchiveLookupRecord>,
351    /// Channel members whose claimed hint has resolved to a full address. The
352    /// platform should upgrade rows it stored anonymously under that hint.
353    pub chat_sender_resolutions: Vec<MobileChatSenderResolutionRecord>,
354    pub chat_diagnostics: Vec<String>,
355}
356
357#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
358pub struct MobileMeshOutboundFrameRecord {
359    pub id: u64,
360    pub data: Vec<u8>,
361    /// `TX_FLAG_NOCCA`: the device should transmit this frame without the
362    /// pre-transmit channel-activity check. Set for immediate MAC acks, which
363    /// own the channel-access window the moment the received frame ends; clear
364    /// for originated and forwarded traffic, which must listen before talking.
365    pub nocca: bool,
366}
367
368#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
369pub struct MobileMeshRxRecord {
370    pub data: Vec<u8>,
371    pub rssi_dbm: Option<i16>,
372    pub lqi: Option<u8>,
373    pub snr_cb: Option<i16>,
374}
375
376enum WorkerCommand {
377    RegisterPeers {
378        peers: Vec<PublicKey>,
379        response: oneshot::Sender<Result<(), MobileMeshError>>,
380    },
381    RemovePeers {
382        peers: Vec<PublicKey>,
383        response: oneshot::Sender<Result<(), MobileMeshError>>,
384    },
385    RegisterChannels {
386        keys: Vec<ChannelKey>,
387        response: oneshot::Sender<Result<(), MobileMeshError>>,
388    },
389    RemoveChannels {
390        keys: Vec<ChannelKey>,
391        response: oneshot::Sender<Result<(), MobileMeshError>>,
392    },
393    Ping {
394        operation_id: u64,
395        peer: PublicKey,
396        timeout_ms: u64,
397    },
398    RestoreChat {
399        checkpoints: Vec<MobileChatCheckpointRecord>,
400        response: oneshot::Sender<()>,
401    },
402    ComposeChat {
403        conversation_address: String,
404        client_token: u32,
405        request: ChatComposeRequest,
406        response: oneshot::Sender<Result<MobileChatComposeBatchRecord, MobileMeshError>>,
407    },
408    CommitChatBatch {
409        batch_id: u64,
410        response: oneshot::Sender<Result<(), MobileMeshError>>,
411    },
412    RejectChatBatch {
413        batch_id: u64,
414        checkpoints: Vec<MobileChatCheckpointRecord>,
415        response: oneshot::Sender<Result<(), MobileMeshError>>,
416    },
417    ChatArchiveResult {
418        request_id: u32,
419        kind: MobileChatArchiveResultKind,
420        payload: Vec<u8>,
421    },
422    Advertise {
423        name: Option<String>,
424        timestamp: Option<u32>,
425        /// Whether this is the phone's own schedule speaking rather than
426        /// someone tapping a button. A scheduled advertisement reaches
427        /// only the neighbours that can hear the phone directly.
428        scheduled: bool,
429        response: oneshot::Sender<Result<(), MobileMeshError>>,
430    },
431    Beacon {
432        response: oneshot::Sender<Result<(), MobileMeshError>>,
433    },
434    SignIdentityBundle {
435        name: Option<String>,
436        timestamp: Option<u32>,
437        response: oneshot::Sender<Result<Vec<u8>, MobileMeshError>>,
438    },
439    RequestIdentity {
440        peer: PublicKey,
441        response: oneshot::Sender<Result<(), MobileMeshError>>,
442    },
443    DiscoverIdentities {
444        role_code: Option<u8>,
445        capability_bits: Option<u8>,
446        response: oneshot::Sender<Result<(), MobileMeshError>>,
447    },
448    RequestIdentityByHint {
449        conversation_address: String,
450        hint: NodeHint,
451        response: oneshot::Sender<Result<(), MobileMeshError>>,
452    },
453    SetDiscoverable {
454        enabled: bool,
455        name: Option<String>,
456        response: oneshot::Sender<()>,
457    },
458    /// Already reduced to the disclosed cell; `None` stops sharing.
459    SetAdvertisedLocation {
460        location: Option<NodeLocation>,
461        response: oneshot::Sender<()>,
462    },
463    SetChatDisplayName {
464        name: String,
465        response: oneshot::Sender<()>,
466    },
467    PeerRoute {
468        peer: PublicKey,
469        response: oneshot::Sender<MobileMeshRouteRecord>,
470    },
471    ClearPeerRoute {
472        peer: PublicKey,
473        response: oneshot::Sender<bool>,
474    },
475    FailOutboundTransmissions,
476    Receive(MobileMeshRxRecord),
477    Shutdown,
478}
479
480enum ChatComposeRequest {
481    Text {
482        body: String,
483    },
484    Edit {
485        original: MobileChatOriginalRef,
486        body: String,
487    },
488    Delete {
489        original: MobileChatOriginalRef,
490    },
491    Reaction {
492        target: MobileChatRegardingRef,
493        body: String,
494    },
495}
496
497struct InboundFrame {
498    record: MobileMeshRxRecord,
499}
500
501/// Who a received text frame came from, and over what.
502enum InboundTextSource {
503    /// Authenticated unicast from a known peer.
504    Direct { peer: PublicKey },
505    /// Multicast to a channel. The sender claims a hint; the full key is
506    /// present only when they addressed the frame with it.
507    ChannelGroup {
508        channel: ChannelTag,
509        hint: NodeHint,
510        full_key: Option<PublicKey>,
511    },
512    /// Blind unicast to us over a channel key, from an addressable peer.
513    ChannelDirect {
514        channel: ChannelTag,
515        peer: PublicKey,
516    },
517}
518
519struct InboundText {
520    source: InboundTextSource,
521    payload: Vec<u8>,
522    received_at_ms: Option<u64>,
523    rx: MobileChatRxMetadataRecord,
524}
525
526struct InFlightChatTransmission {
527    transmission_id: u32,
528    /// The peer whose first-contact pipeline this send gates on. Channel
529    /// sends have no such peer: multicast is unaddressed and blind unicast
530    /// carries no ACK to confirm with.
531    gate_peer: Option<PublicKey>,
532    ticket: SendProgressTicket,
533    sent_reported: bool,
534    /// No acknowledgement will ever arrive, so transmission is the terminal
535    /// success state rather than a step toward one.
536    non_ack: bool,
537    /// When this entry entered the window, and whether it has already been
538    /// reported as overdue. A frame that never resolves holds a slot in a
539    /// window of eight; eight of them stop chat entirely, and from the outside
540    /// that looks like messages hanging on "Sending" for no reason.
541    queued_at_ms: u64,
542    stall_reported: bool,
543}
544
545/// How long an in-flight transmission may go unresolved before it is called
546/// out. Longer than any ordinary ACK wait, so this fires on trouble rather
547/// than on a slow link.
548const CHAT_TRANSMISSION_STALL_MS: u64 = 60_000;
549
550#[derive(Clone)]
551enum MobileChatWorkerEvent {
552    Mutation(MobileChatMutationRecord),
553    SenderResolution(MobileChatSenderResolutionRecord),
554    Delivery(MobileChatDeliveryRecord),
555    ArchiveLookup(MobileChatArchiveLookupRecord),
556    Diagnostic(String),
557}
558
559struct PendingChatEventBatch {
560    id: u64,
561    events: Vec<MobileChatWorkerEvent>,
562}
563
564#[derive(Debug)]
565enum BridgeRadioError {
566    Closed,
567    FrameTooLarge,
568}
569
570struct BridgeTransmitCompletions {
571    next_id: AtomicU64,
572    failure_generation: AtomicU64,
573    /// A link-wide failure was declared and its `FailOutboundTransmissions`
574    /// command has not yet been processed by the worker. While set, no new
575    /// transmission may reach the platform: the MAC's in-progress drain loop
576    /// would otherwise keep dispatching the frames queued behind the one the
577    /// failure caught mid-flight, because each later `transmit` call samples
578    /// the generation only after the bump. The worker clears the flag when
579    /// it processes the queued command and cancels the affected tickets.
580    poisoned: AtomicBool,
581    pending: Mutex<BTreeMap<u64, oneshot::Sender<bool>>>,
582}
583
584impl BridgeTransmitCompletions {
585    fn new() -> Self {
586        Self {
587            next_id: AtomicU64::new(1),
588            failure_generation: AtomicU64::new(0),
589            poisoned: AtomicBool::new(false),
590            pending: Mutex::new(BTreeMap::new()),
591        }
592    }
593
594    fn generation(&self) -> u64 {
595        self.failure_generation.load(Ordering::SeqCst)
596    }
597
598    fn allocate(
599        &self,
600        generation: u64,
601        completion: oneshot::Sender<bool>,
602    ) -> Result<Option<u64>, BridgeRadioError> {
603        let id = self.next_id.fetch_add(1, Ordering::Relaxed).max(1);
604        let mut pending = self.pending.lock().map_err(|_| BridgeRadioError::Closed)?;
605        if self.poisoned.load(Ordering::SeqCst)
606            || generation != self.failure_generation.load(Ordering::SeqCst)
607        {
608            return Ok(None);
609        }
610        pending.insert(id, completion);
611        Ok(Some(id))
612    }
613
614    /// Declare a link-wide failure: refuse new platform dispatches until the
615    /// worker processes the corresponding cancellation command.
616    fn poison(&self) {
617        self.poisoned.store(true, Ordering::SeqCst);
618        self.failure_generation.fetch_add(1, Ordering::SeqCst);
619    }
620
621    fn clear_poison(&self) {
622        self.poisoned.store(false, Ordering::SeqCst);
623    }
624
625    fn complete(&self, id: u64, transmitted: bool) -> bool {
626        self.pending
627            .lock()
628            .ok()
629            .and_then(|mut pending| pending.remove(&id))
630            .is_some_and(|completion| completion.send(transmitted).is_ok())
631    }
632
633    fn fail_all(&self) {
634        self.failure_generation.fetch_add(1, Ordering::SeqCst);
635        let completions = self
636            .pending
637            .lock()
638            .map(|mut pending| core::mem::take(&mut *pending))
639            .unwrap_or_default();
640        for completion in completions.into_values() {
641            let _ = completion.send(false);
642        }
643    }
644}
645
646struct BridgeRadio {
647    inbound: mpsc::UnboundedReceiver<InboundFrame>,
648    outbound: NotifyingSender<MobileMeshOutboundFrameRecord>,
649    completions: Arc<BridgeTransmitCompletions>,
650}
651
652impl Radio for BridgeRadio {
653    type Error = BridgeRadioError;
654
655    async fn transmit(
656        &mut self,
657        data: &[u8],
658        options: TxOptions,
659    ) -> Result<(), TxError<Self::Error>> {
660        if data.len() > MAX_FRAME_SIZE {
661            return Err(TxError::Io(BridgeRadioError::FrameTooLarge));
662        }
663        // The MAC skips CAD only for immediate acks (channel-access.md
664        // § Immediate ACK Transmission); every other policy asks the
665        // device to listen before talking.
666        let nocca = matches!(options.cad, umsh_hal::CadPolicy::Skip);
667        let (completion_tx, completion_rx) = oneshot::channel();
668        let generation = self.completions.generation();
669        let Some(id) = self
670            .completions
671            .allocate(generation, completion_tx)
672            .map_err(TxError::Io)?
673        else {
674            // A link-wide failure raced this send before it reached the
675            // platform. Its queued cancellation owns the ticket outcome.
676            return Ok(());
677        };
678        if self
679            .outbound
680            .send(MobileMeshOutboundFrameRecord {
681                id,
682                data: data.to_vec(),
683                nocca,
684            })
685            .is_err()
686        {
687            let _ = self.completions.complete(id, false);
688            return Err(TxError::Io(BridgeRadioError::Closed));
689        }
690
691        // Awaiting here is deliberate: Radio::transmit completes only after
692        // the frame has actually left the radio PHY. Returning at
693        // bridge-queue acceptance starts MAC ACK timers too early and causes
694        // fragmented sends to retransmit frames that are still waiting in
695        // the device queue. This is an async wait, not a thread block, so
696        // the worker keeps servicing commands and timers while the frame is
697        // in flight; the MAC itself stays serialized behind its own borrow.
698        match completion_rx.await {
699            Ok(true) => Ok(()),
700            // The public completion API poisons the bridge and queues
701            // FailOutboundTransmissions before releasing this wait. Return
702            // success here solely to keep an ordinary rejected frame from
703            // terminating the long-lived MAC driver; the queued command
704            // cancels its ACK ticket immediately.
705            Ok(false) => Ok(()),
706            Err(_) => Err(TxError::Io(BridgeRadioError::Closed)),
707        }
708    }
709
710    fn poll_receive(
711        &mut self,
712        cx: &mut Context<'_>,
713        buf: &mut [u8],
714    ) -> Poll<Result<RxInfo, Self::Error>> {
715        match self.inbound.poll_recv(cx) {
716            Poll::Ready(Some(frame)) => {
717                if frame.record.data.len() > buf.len() {
718                    return Poll::Ready(Err(BridgeRadioError::FrameTooLarge));
719                }
720                let len = frame.record.data.len();
721                buf[..len].copy_from_slice(&frame.record.data);
722                Poll::Ready(Ok(RxInfo {
723                    len,
724                    rssi: frame.record.rssi_dbm.unwrap_or(0),
725                    snr: Snr::from_centibels(frame.record.snr_cb.unwrap_or(0)),
726                    lqi: frame.record.lqi.and_then(core::num::NonZeroU8::new),
727                }))
728            }
729            Poll::Ready(None) => Poll::Ready(Err(BridgeRadioError::Closed)),
730            Poll::Pending => Poll::Pending,
731        }
732    }
733
734    fn max_frame_size(&self) -> usize {
735        MAX_FRAME_SIZE
736    }
737    fn t_frame_ms(&self) -> u32 {
738        DEFAULT_FRAME_TIME_MS
739    }
740}
741
742#[derive(Clone)]
743struct SharedCounterStore(Arc<MobileCounterStore>);
744
745impl CounterStore for SharedCounterStore {
746    type Error = crate::CounterStoreError;
747
748    async fn load(&self, context: &[u8]) -> Result<u32, Self::Error> {
749        self.0.load_boundary(context.to_vec())
750    }
751
752    async fn store(&self, context: &[u8], value: u32) -> Result<(), Self::Error> {
753        self.0.commit_boundary(context.to_vec(), value)
754    }
755
756    async fn flush(&self) -> Result<(), Self::Error> {
757        CounterStore::flush(self.0.as_ref()).await
758    }
759}
760
761#[derive(Clone, Default)]
762struct MemoryKeyValueStore(Arc<Mutex<BTreeMap<Vec<u8>, Vec<u8>>>>);
763
764impl KeyValueStore for MemoryKeyValueStore {
765    type Error = MobileMeshError;
766
767    async fn load(&self, key: &[u8], out: &mut [u8]) -> Result<Option<usize>, Self::Error> {
768        let values = self
769            .0
770            .lock()
771            .map_err(|_| MobileMeshError::SessionUnavailable)?;
772        let Some(value) = values.get(key) else {
773            return Ok(None);
774        };
775        if value.len() > out.len() {
776            return Err(MobileMeshError::SessionUnavailable);
777        }
778        out[..value.len()].copy_from_slice(value);
779        Ok(Some(value.len()))
780    }
781
782    async fn store(&self, key: &[u8], value: &[u8]) -> Result<(), Self::Error> {
783        self.0
784            .lock()
785            .map_err(|_| MobileMeshError::SessionUnavailable)?
786            .insert(key.to_vec(), value.to_vec());
787        Ok(())
788    }
789
790    async fn delete(&self, key: &[u8]) -> Result<(), Self::Error> {
791        self.0
792            .lock()
793            .map_err(|_| MobileMeshError::SessionUnavailable)?
794            .remove(key);
795        Ok(())
796    }
797}
798
799/// MAC clock backed by tokio's time source. Using `tokio::time::Instant`
800/// (rather than `std::time::Instant`) means a runtime started with paused
801/// time drives this clock too, so every MAC deadline can be fast-forwarded
802/// deterministically in tests.
803#[derive(Clone)]
804struct MobileClock {
805    origin: tokio::time::Instant,
806    sleep: Rc<RefCell<Option<Pin<Box<tokio::time::Sleep>>>>>,
807}
808
809impl MobileClock {
810    fn new() -> Self {
811        Self {
812            origin: tokio::time::Instant::now(),
813            sleep: Rc::new(RefCell::new(None)),
814        }
815    }
816}
817
818impl Clock for MobileClock {
819    fn now_ms(&self) -> u64 {
820        self.origin.elapsed().as_millis() as u64
821    }
822
823    fn poll_delay_until(&self, cx: &mut Context<'_>, deadline_ms: u64) -> Poll<()> {
824        let deadline = self.origin + Duration::from_millis(deadline_ms);
825        if tokio::time::Instant::now() >= deadline {
826            return Poll::Ready(());
827        }
828        let mut slot = self.sleep.borrow_mut();
829        let sleep = slot.get_or_insert_with(|| Box::pin(tokio::time::sleep_until(deadline)));
830        sleep.as_mut().reset(deadline);
831        sleep.as_mut().poll(cx)
832    }
833}
834
835#[derive(Clone, Copy, Default)]
836struct MobileDelay;
837
838impl DelayNs for MobileDelay {
839    async fn delay_ns(&mut self, ns: u32) {
840        tokio::time::sleep(Duration::from_nanos(u64::from(ns))).await;
841    }
842}
843
844struct MobilePlatform(PhantomData<()>);
845
846impl umsh_mac::Platform for MobilePlatform {
847    type Identity = SoftwareIdentity;
848    type Aes = SoftwareAes;
849    type Sha = SoftwareSha256;
850    type Radio = BridgeRadio;
851    type Delay = MobileDelay;
852    type Clock = MobileClock;
853    type Rng = rand::rngs::ThreadRng;
854    type CounterStore = SharedCounterStore;
855    type KeyValueStore = MemoryKeyValueStore;
856}
857
858/// Peer capacity of the phone's in-memory MAC. The embedded default (16) is
859/// sized for microcontroller RAM; the app registers a peer per conversation
860/// plus every checkpointed stream, which can plausibly exceed it, and phone
861/// RAM is not the constraint.
862const MOBILE_MAC_PEERS: usize = 64;
863
864/// Channel capacity of the phone's in-memory MAC. The embedded default of 8
865/// is sized for microcontroller RAM and already spends two slots on the
866/// default `public` and `EMERGENCY` channels; per-channel replay state is a
867/// few hundred bytes, which phone RAM does not need to ration.
868const MOBILE_MAC_CHANNELS: usize = 32;
869
870type MobileMac =
871    Mac<MobilePlatform, { umsh_mac::DEFAULT_IDENTITIES }, MOBILE_MAC_PEERS, MOBILE_MAC_CHANNELS>;
872const MOBILE_CHAT_TRANSMIT_WINDOW: usize = 8;
873
874/// Long-lived Rust protocol engine used by the mobile app.
875///
876/// `ping` is the only ping operation exposed to Swift. The existing Rust node
877/// layer owns its nonce, authenticated echo request, counter reservation,
878/// response matching, and timeout.
879#[derive(uniffi::Object)]
880pub struct MobileMeshSession {
881    commands: mpsc::UnboundedSender<WorkerCommand>,
882    outbound: Mutex<std_mpsc::Receiver<MobileMeshOutboundFrameRecord>>,
883    transmit_completions: Arc<BridgeTransmitCompletions>,
884    events: Mutex<std_mpsc::Receiver<MobileMeshPingEventRecord>>,
885    advertisements: Mutex<std_mpsc::Receiver<MobileMeshAdvertisementRecord>>,
886    peer_heard: Mutex<std_mpsc::Receiver<MobileMeshPeerHeardRecord>>,
887    chat_events: Mutex<std_mpsc::Receiver<MobileChatWorkerEvent>>,
888    pending_chat_events: Mutex<Option<PendingChatEventBatch>>,
889    next_chat_batch_id: Mutex<u64>,
890    next_operation_id: Mutex<u64>,
891    wake: Arc<WakeSignal>,
892}
893
894#[uniffi::export]
895impl MobileMeshSession {
896    #[uniffi::constructor]
897    pub async fn new(
898        identity: Arc<MobileIdentity>,
899        counter_store: Arc<MobileCounterStore>,
900    ) -> Result<Arc<Self>, MobileMeshError> {
901        Self::build(identity, counter_store, false).await
902    }
903
904    pub fn ping(&self, peer_address: String, timeout_ms: u64) -> Result<u64, MobileMeshError> {
905        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
906        let operation_id = {
907            let mut next = self
908                .next_operation_id
909                .lock()
910                .map_err(|_| MobileMeshError::SessionUnavailable)?;
911            let current = *next;
912            *next = next.wrapping_add(1).max(1);
913            current
914        };
915        self.commands
916            .send(WorkerCommand::Ping {
917                operation_id,
918                peer,
919                timeout_ms,
920            })
921            .map_err(|_| MobileMeshError::SessionUnavailable)?;
922        Ok(operation_id)
923    }
924
925    /// Broadcast a signed node-identity advertisement describing this phone.
926    ///
927    /// The bundle always carries the standalone EdDSA signature because a
928    /// broadcast frame has no MIC to authenticate it.
929    pub async fn advertise_identity(
930        &self,
931        name: Option<String>,
932        timestamp: Option<u32>,
933    ) -> Result<(), MobileMeshError> {
934        self.send_advertisement(name, timestamp, false).await
935    }
936
937    /// The same advertisement, sent because the phone's own interval came
938    /// round rather than because someone asked for it.
939    ///
940    /// Reaches only direct neighbours. A repeated statement of who this
941    /// phone is does not need to cross the mesh every time; introducing
942    /// it, which is what the manual send does, is the case that does.
943    pub async fn advertise_identity_scheduled(
944        &self,
945        name: Option<String>,
946        timestamp: Option<u32>,
947    ) -> Result<(), MobileMeshError> {
948        self.send_advertisement(name, timestamp, true).await
949    }
950
951    async fn send_advertisement(
952        &self,
953        name: Option<String>,
954        timestamp: Option<u32>,
955        scheduled: bool,
956    ) -> Result<(), MobileMeshError> {
957        let (response, result) = oneshot::channel();
958        self.commands
959            .send(WorkerCommand::Advertise {
960                name,
961                timestamp,
962                scheduled,
963                response,
964            })
965            .map_err(|_| MobileMeshError::SessionUnavailable)?;
966        result
967            .await
968            .map_err(|_| MobileMeshError::SessionUnavailable)?
969    }
970
971    /// Broadcast an empty beacon: no payload, so what it publishes is the
972    /// path back to this phone rather than who this phone is. Costs a
973    /// fraction of an advertisement.
974    pub async fn send_beacon(&self) -> Result<(), MobileMeshError> {
975        let (response, result) = oneshot::channel();
976        self.commands
977            .send(WorkerCommand::Beacon { response })
978            .map_err(|_| MobileMeshError::SessionUnavailable)?;
979        result
980            .await
981            .map_err(|_| MobileMeshError::SessionUnavailable)?
982    }
983
984    /// Solicit a specific peer's current node identity by sending a targeted
985    /// MAC Identity Request (command 1). This resolves once the request has
986    /// been handed to the transport; the peer's identity response arrives
987    /// later as a `NodeIdentity` advertisement on the normal receive path
988    /// (surfaced through `poll_update`'s advertisement events).
989    pub async fn request_identity(&self, peer_address: String) -> Result<(), MobileMeshError> {
990        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
991        let (response, result) = oneshot::channel();
992        self.commands
993            .send(WorkerCommand::RequestIdentity { peer, response })
994            .map_err(|_| MobileMeshError::SessionUnavailable)?;
995        result
996            .await
997            .map_err(|_| MobileMeshError::SessionUnavailable)?
998    }
999
1000    /// Solicit identities from nearby nodes with one zero-hop broadcast MAC
1001    /// Identity Request.
1002    ///
1003    /// The request goes out as a direct broadcast with no flood budget, so
1004    /// repeaters never carry it — the blast radius is exactly the nodes in
1005    /// radio range. It carries this phone's full source address, so a
1006    /// matching node can reply with a targeted unicast without any prior
1007    /// contact; replies arrive as ordinary `NodeIdentity` advertisements on
1008    /// the receive path. `role_code` and `capability_bits` narrow which
1009    /// nodes respond (AND-combined when both are given); `None` for both
1010    /// asks every node in range.
1011    pub async fn discover_identities(
1012        &self,
1013        role_code: Option<u8>,
1014        capability_bits: Option<u8>,
1015    ) -> Result<(), MobileMeshError> {
1016        let (response, result) = oneshot::channel();
1017        self.commands
1018            .send(WorkerCommand::DiscoverIdentities {
1019                role_code,
1020                capability_bits,
1021                response,
1022            })
1023            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1024        result
1025            .await
1026            .map_err(|_| MobileMeshError::SessionUnavailable)?
1027    }
1028
1029    /// Ask a channel member who is known only by their claimed hint to send
1030    /// their identity.
1031    ///
1032    /// A group message carries a 3-byte hint and nothing else, so there is no
1033    /// address to unicast a request to. This goes out over the channel itself,
1034    /// filtered to that hint, and only the member it names answers — with a
1035    /// targeted unicast, since the request carries this phone's full address.
1036    ///
1037    /// The request is routed by what that member's own frames have shown:
1038    /// their observed trace route if one is known, otherwise a flood budget
1039    /// bounded by the hops their last message took rather than a default.
1040    pub async fn request_identity_by_hint(
1041        &self,
1042        conversation_address: String,
1043        hint: Vec<u8>,
1044    ) -> Result<(), MobileMeshError> {
1045        let hint: [u8; 3] = hint
1046            .try_into()
1047            .map_err(|_| MobileMeshError::UnknownConversation)?;
1048        let (response, result) = oneshot::channel();
1049        self.commands
1050            .send(WorkerCommand::RequestIdentityByHint {
1051                conversation_address,
1052                hint: NodeHint(hint),
1053                response,
1054            })
1055            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1056        result
1057            .await
1058            .map_err(|_| MobileMeshError::SessionUnavailable)?
1059    }
1060
1061    /// Set whether this phone answers Identity Requests with its own
1062    /// identity — the passive counterpart of [`discover_identities`]:
1063    /// discoverable phones show up in other people's Discover sessions.
1064    ///
1065    /// `name` is the display name carried in replies (truncated to the
1066    /// 24-byte wire limit). The session starts discoverable with no name;
1067    /// the app pushes the stored preference and name right after install
1068    /// and again whenever either changes. Replies are targeted
1069    /// authenticated unicasts, never broadcasts.
1070    /// Set the name carried on this phone's own group messages.
1071    ///
1072    /// A multicast reaches members holding no identity for us, so a group
1073    /// message says who sent it or arrives anonymous. Direct messages never
1074    /// carry it: the recipient authenticated us by key. Empty clears it.
1075    pub async fn set_chat_display_name(&self, name: String) -> Result<(), MobileMeshError> {
1076        let (response, result) = oneshot::channel();
1077        self.commands
1078            .send(WorkerCommand::SetChatDisplayName { name, response })
1079            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1080        result
1081            .await
1082            .map_err(|_| MobileMeshError::SessionUnavailable)
1083    }
1084
1085    pub async fn set_discoverable(
1086        &self,
1087        enabled: bool,
1088        name: Option<String>,
1089    ) -> Result<(), MobileMeshError> {
1090        let (response, result) = oneshot::channel();
1091        self.commands
1092            .send(WorkerCommand::SetDiscoverable {
1093                enabled,
1094                name,
1095                response,
1096            })
1097            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1098        result
1099            .await
1100            .map_err(|_| MobileMeshError::SessionUnavailable)
1101    }
1102
1103    /// Set the position this phone's identity carries, or `None` to stop
1104    /// sharing one.
1105    ///
1106    /// Reaches every *live* identity payload — advertisements, manual and
1107    /// scheduled, and Identity Request replies while discoverable — but
1108    /// never the shareable QR/URI bundle: that bundle is durable, and a
1109    /// position frozen into it would go stale and then travel wherever
1110    /// the QR is pasted. The coordinate is reduced to the cell named by
1111    /// `precision_bytes` before it is stored, so nothing finer ever sits
1112    /// in this session, whatever later reads it.
1113    pub async fn set_advertised_location(
1114        &self,
1115        location: Option<MobileMeshSharedLocationRecord>,
1116    ) -> Result<(), MobileMeshError> {
1117        let location = location.map(disclosed_cell).transpose()?;
1118        let (response, result) = oneshot::channel();
1119        self.commands
1120            .send(WorkerCommand::SetAdvertisedLocation { location, response })
1121            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1122        result
1123            .await
1124            .map_err(|_| MobileMeshError::SessionUnavailable)
1125    }
1126
1127    /// Report the route the MAC will use for the next frame sent to `peer`.
1128    ///
1129    /// Read-only: an unregistered peer reads as `Unknown` rather than being
1130    /// registered as a side effect of being inspected.
1131    pub async fn peer_route(
1132        &self,
1133        peer_address: String,
1134    ) -> Result<MobileMeshRouteRecord, MobileMeshError> {
1135        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
1136        let (response, result) = oneshot::channel();
1137        self.commands
1138            .send(WorkerCommand::PeerRoute { peer, response })
1139            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1140        result
1141            .await
1142            .map_err(|_| MobileMeshError::SessionUnavailable)
1143    }
1144
1145    /// Forget the route cached for `peer`, returning whether one was held.
1146    ///
1147    /// The peer, its keys, and its counters are untouched; only the learned
1148    /// path is discarded, so the next send starts over from flood delivery.
1149    pub async fn clear_peer_route(&self, peer_address: String) -> Result<bool, MobileMeshError> {
1150        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
1151        let (response, result) = oneshot::channel();
1152        self.commands
1153            .send(WorkerCommand::ClearPeerRoute { peer, response })
1154            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1155        result
1156            .await
1157            .map_err(|_| MobileMeshError::SessionUnavailable)
1158    }
1159
1160    /// Build and sign this phone's node-identity bundle without transmitting
1161    /// it, for embedding in the shareable `umsh:n:` URI and QR code.
1162    pub async fn sign_identity_bundle(
1163        &self,
1164        name: Option<String>,
1165        timestamp: Option<u32>,
1166    ) -> Result<Vec<u8>, MobileMeshError> {
1167        let (response, result) = oneshot::channel();
1168        self.commands
1169            .send(WorkerCommand::SignIdentityBundle {
1170                name,
1171                timestamp,
1172                response,
1173            })
1174            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1175        result
1176            .await
1177            .map_err(|_| MobileMeshError::SessionUnavailable)?
1178    }
1179
1180    pub async fn register_peers(&self, peer_addresses: Vec<String>) -> Result<(), MobileMeshError> {
1181        let peers = peer_addresses
1182            .iter()
1183            .map(|address| decode_peer(address).map_err(|_| MobileMeshError::InvalidPeer))
1184            .collect::<Result<Vec<_>, _>>()?;
1185        let (response, result) = oneshot::channel();
1186        self.commands
1187            .send(WorkerCommand::RegisterPeers { peers, response })
1188            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1189        result
1190            .await
1191            .map_err(|_| MobileMeshError::SessionUnavailable)?
1192    }
1193
1194    /// Remove peers from the live MAC. Idempotent: a peer that was never
1195    /// registered is already in the requested state, so it is not an error.
1196    /// A removed peer that transmits again may be auto-re-registered
1197    /// (unpinned) by the MAC — removal here tracks the app's stored peer
1198    /// list, it is not a block list.
1199    pub async fn remove_peers(&self, peer_addresses: Vec<String>) -> Result<(), MobileMeshError> {
1200        let peers = peer_addresses
1201            .iter()
1202            .map(|address| decode_peer(address).map_err(|_| MobileMeshError::InvalidPeer))
1203            .collect::<Result<Vec<_>, _>>()?;
1204        let (response, result) = oneshot::channel();
1205        self.commands
1206            .send(WorkerCommand::RemovePeers { peers, response })
1207            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1208        result
1209            .await
1210            .map_err(|_| MobileMeshError::SessionUnavailable)?
1211    }
1212
1213    /// Register channel keys with the live MAC so their traffic is accepted.
1214    ///
1215    /// Membership itself is persisted by the platform, which replays the whole
1216    /// joined set through this call when a session starts. Re-registering a
1217    /// channel already held is harmless.
1218    pub async fn register_channels(&self, keys: Vec<Vec<u8>>) -> Result<(), MobileMeshError> {
1219        let keys = decode_channel_keys(keys)?;
1220        let (response, result) = oneshot::channel();
1221        self.commands
1222            .send(WorkerCommand::RegisterChannels { keys, response })
1223            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1224        result
1225            .await
1226            .map_err(|_| MobileMeshError::SessionUnavailable)?
1227    }
1228
1229    /// Drop channel keys from the live MAC, so its traffic is no longer
1230    /// decrypted. Idempotent, like [`Self::remove_peers`].
1231    pub async fn remove_channels(&self, keys: Vec<Vec<u8>>) -> Result<(), MobileMeshError> {
1232        let keys = decode_channel_keys(keys)?;
1233        let (response, result) = oneshot::channel();
1234        self.commands
1235            .send(WorkerCommand::RemoveChannels { keys, response })
1236            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1237        result
1238            .await
1239            .map_err(|_| MobileMeshError::SessionUnavailable)?
1240    }
1241
1242    pub fn receive(&self, frame: MobileMeshRxRecord) -> Result<(), MobileMeshError> {
1243        if frame.data.is_empty() || frame.data.len() > MAX_FRAME_SIZE {
1244            return Err(MobileMeshError::SessionUnavailable);
1245        }
1246        self.commands
1247            .send(WorkerCommand::Receive(frame))
1248            .map_err(|_| MobileMeshError::SessionUnavailable)
1249    }
1250
1251    /// Report the actual physical radio result for an outbound
1252    /// frame. This is intentionally distinct from accepting the frame into the
1253    /// BLE/CRP queue: the MAC starts ACK and retry timing only after success.
1254    pub fn complete_outbound_frame(
1255        &self,
1256        frame_id: u64,
1257        transmitted: bool,
1258    ) -> Result<(), MobileMeshError> {
1259        if !transmitted {
1260            // A rejected frame fails the whole outbound batch. Poison before
1261            // releasing this frame's wait so the MAC drain cannot dispatch
1262            // the frames queued behind it (see fail_outbound_transmissions).
1263            self.transmit_completions.poison();
1264            self.commands
1265                .send(WorkerCommand::FailOutboundTransmissions)
1266                .map_err(|_| MobileMeshError::SessionUnavailable)?;
1267        }
1268        self.transmit_completions
1269            .complete(frame_id, transmitted)
1270            .then_some(())
1271            .ok_or(MobileMeshError::SessionUnavailable)
1272    }
1273
1274    pub async fn restore_chat(
1275        &self,
1276        checkpoints: Vec<MobileChatCheckpointRecord>,
1277    ) -> Result<(), MobileMeshError> {
1278        let (response, result) = oneshot::channel();
1279        self.commands
1280            .send(WorkerCommand::RestoreChat {
1281                checkpoints,
1282                response,
1283            })
1284            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1285        result
1286            .await
1287            .map_err(|_| MobileMeshError::SessionUnavailable)
1288    }
1289
1290    /// Compose a message into a conversation, addressed either by a peer's
1291    /// address or by a channel's conversation address.
1292    pub async fn compose_text(
1293        &self,
1294        conversation_address: String,
1295        client_token: u32,
1296        body: String,
1297    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1298        self.compose_chat(
1299            conversation_address,
1300            client_token,
1301            ChatComposeRequest::Text { body },
1302        )
1303        .await
1304    }
1305
1306    /// Compose an edit of a previously sent message. The original may come
1307    /// from an earlier app launch: its persisted `(wire_id, epoch)` is used
1308    /// when the facade session no longer holds a live handle, and the engine
1309    /// rejects it (`ChatComposeFailed`) if stream continuity was lost since.
1310    pub async fn compose_edit(
1311        &self,
1312        conversation_address: String,
1313        client_token: u32,
1314        original: MobileChatOriginalRef,
1315        body: String,
1316    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1317        self.compose_chat(
1318            conversation_address,
1319            client_token,
1320            ChatComposeRequest::Edit { original, body },
1321        )
1322        .await
1323    }
1324
1325    /// Compose a deletion (empty edit on the wire) of a previously sent
1326    /// message. Same original-reference rules as [`Self::compose_edit`].
1327    pub async fn compose_delete(
1328        &self,
1329        conversation_address: String,
1330        client_token: u32,
1331        original: MobileChatOriginalRef,
1332    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1333        self.compose_chat(
1334            conversation_address,
1335            client_token,
1336            ChatComposeRequest::Delete { original },
1337        )
1338        .await
1339    }
1340
1341    /// React to a message with a short emote body, or withdraw an earlier
1342    /// reaction by passing an empty body. A sender has at most one live
1343    /// reaction per message: sending another simply supersedes it, so there
1344    /// is nothing to edit or delete.
1345    ///
1346    /// Unlike an edit, the target may be a message the peer sent, and usually
1347    /// one persisted before this launch; the reference carries the direction
1348    /// and (for channel groups) the sender hint needed to name it.
1349    pub async fn compose_reaction(
1350        &self,
1351        conversation_address: String,
1352        client_token: u32,
1353        target: MobileChatRegardingRef,
1354        body: String,
1355    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1356        self.compose_chat(
1357            conversation_address,
1358            client_token,
1359            ChatComposeRequest::Reaction { target, body },
1360        )
1361        .await
1362    }
1363
1364    pub async fn commit_chat_batch(&self, batch_id: u64) -> Result<(), MobileMeshError> {
1365        let (response, result) = oneshot::channel();
1366        self.commands
1367            .send(WorkerCommand::CommitChatBatch { batch_id, response })
1368            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1369        result
1370            .await
1371            .map_err(|_| MobileMeshError::SessionUnavailable)?
1372    }
1373
1374    pub async fn reject_chat_batch(
1375        &self,
1376        batch_id: u64,
1377        checkpoints: Vec<MobileChatCheckpointRecord>,
1378    ) -> Result<(), MobileMeshError> {
1379        let (response, result) = oneshot::channel();
1380        self.commands
1381            .send(WorkerCommand::RejectChatBatch {
1382                batch_id,
1383                checkpoints,
1384                response,
1385            })
1386            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1387        result
1388            .await
1389            .map_err(|_| MobileMeshError::SessionUnavailable)?
1390    }
1391
1392    pub fn apply_chat_archive_result(
1393        &self,
1394        request_id: u32,
1395        kind: MobileChatArchiveResultKind,
1396        payload: Vec<u8>,
1397    ) -> Result<(), MobileMeshError> {
1398        self.commands
1399            .send(WorkerCommand::ChatArchiveResult {
1400                request_id,
1401                kind,
1402                payload,
1403            })
1404            .map_err(|_| MobileMeshError::SessionUnavailable)
1405    }
1406
1407    pub fn acknowledge_chat_batch(&self, batch_id: u64) -> Result<(), MobileMeshError> {
1408        let mut pending = self
1409            .pending_chat_events
1410            .lock()
1411            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1412        if pending.as_ref().is_some_and(|batch| batch.id == batch_id) {
1413            *pending = None;
1414            // Events that queued while this batch was outstanding could not
1415            // form a new batch; poke the listener so the platform drains
1416            // again now that the slot is free.
1417            self.wake.notify();
1418        }
1419        Ok(())
1420    }
1421
1422    /// Fail every chat transmission currently owned by the mobile radio
1423    /// bridge. The platform calls this when ULCP-link delivery failed
1424    /// after the MAC had accepted the frames, ensuring optimistic UI rows do
1425    /// not remain in `Sending` indefinitely.
1426    pub fn fail_outbound_transmissions(&self) -> Result<(), MobileMeshError> {
1427        // Poison before anything else: from this instant until the worker
1428        // processes the command below (the sole clearer), every frame the
1429        // MAC's in-progress drain loop tries to hand to the platform is
1430        // suppressed instead of dispatched. Without this, releasing the
1431        // blocked transmit lets the drain advance to the next queued frame,
1432        // which samples the post-bump generation and goes out as if healthy.
1433        self.transmit_completions.poison();
1434        self.commands
1435            .send(WorkerCommand::FailOutboundTransmissions)
1436            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1437        // Release a transmit wait already in progress; the drain it unblocks
1438        // is defused by the poison above.
1439        self.transmit_completions.fail_all();
1440        Ok(())
1441    }
1442
1443    /// Register (or replace) the listener that is told when this session
1444    /// has new data for `poll_update`. If data is already pending, the
1445    /// listener fires immediately.
1446    pub fn set_wake_listener(&self, listener: Arc<dyn MobileMeshWakeListener>) {
1447        self.wake.set_listener(Some(listener));
1448    }
1449
1450    pub fn clear_wake_listener(&self) {
1451        self.wake.set_listener(None);
1452    }
1453
1454    pub fn poll_update(&self) -> MobileMeshSessionUpdateRecord {
1455        // Re-arm before draining: anything enqueued mid-drain triggers a
1456        // fresh notification rather than being silently swallowed.
1457        self.wake.drained();
1458        let mut outbound_frames = Vec::new();
1459        if let Ok(receiver) = self.outbound.lock() {
1460            outbound_frames.extend(receiver.try_iter());
1461        }
1462        let mut ping_events = Vec::new();
1463        if let Ok(receiver) = self.events.lock() {
1464            ping_events.extend(receiver.try_iter());
1465        }
1466        let mut advertisement_events = Vec::new();
1467        if let Ok(receiver) = self.advertisements.lock() {
1468            advertisement_events.extend(receiver.try_iter());
1469        }
1470        let mut peer_heard_events = Vec::new();
1471        if let Ok(receiver) = self.peer_heard.lock() {
1472            peer_heard_events.extend(receiver.try_iter());
1473        }
1474        let mut chat_mutations = Vec::new();
1475        let mut chat_deliveries = Vec::new();
1476        let mut chat_archive_lookups = Vec::new();
1477        let mut chat_sender_resolutions = Vec::new();
1478        let mut chat_diagnostics = Vec::new();
1479        let mut chat_batch_id = None;
1480        if let Ok(mut pending) = self.pending_chat_events.lock() {
1481            if pending.is_none()
1482                && let Ok(receiver) = self.chat_events.lock()
1483            {
1484                let events = receiver.try_iter().collect::<Vec<_>>();
1485                if !events.is_empty()
1486                    && let Ok(mut next) = self.next_chat_batch_id.lock()
1487                {
1488                    let id = *next;
1489                    *next = next.wrapping_add(1).max(1);
1490                    *pending = Some(PendingChatEventBatch { id, events });
1491                }
1492            }
1493            if let Some(batch) = pending.as_ref() {
1494                chat_batch_id = Some(batch.id);
1495                for event in batch.events.iter().cloned() {
1496                    match event {
1497                        MobileChatWorkerEvent::Mutation(record) => chat_mutations.push(record),
1498                        MobileChatWorkerEvent::Delivery(record) => chat_deliveries.push(record),
1499                        MobileChatWorkerEvent::ArchiveLookup(record) => {
1500                            chat_archive_lookups.push(record);
1501                        }
1502                        MobileChatWorkerEvent::SenderResolution(record) => {
1503                            chat_sender_resolutions.push(record);
1504                        }
1505                        MobileChatWorkerEvent::Diagnostic(record) => chat_diagnostics.push(record),
1506                    }
1507                }
1508            }
1509        }
1510        MobileMeshSessionUpdateRecord {
1511            outbound_frames,
1512            ping_events,
1513            advertisement_events,
1514            peer_heard_events,
1515            chat_batch_id,
1516            chat_mutations,
1517            chat_deliveries,
1518            chat_archive_lookups,
1519            chat_sender_resolutions,
1520            chat_diagnostics,
1521        }
1522    }
1523}
1524
1525impl MobileMeshSession {
1526    async fn compose_chat(
1527        &self,
1528        conversation_address: String,
1529        client_token: u32,
1530        request: ChatComposeRequest,
1531    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1532        // Resolved on the worker, which owns the channel registry an address
1533        // may need to be interpreted against.
1534        let (response, result) = oneshot::channel();
1535        self.commands
1536            .send(WorkerCommand::ComposeChat {
1537                conversation_address,
1538                client_token,
1539                request,
1540                response,
1541            })
1542            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1543        result
1544            .await
1545            .map_err(|_| MobileMeshError::SessionUnavailable)?
1546    }
1547
1548    /// Construct a session whose worker runtime starts with tokio's clock
1549    /// paused (test builds only). Timers auto-advance whenever the worker is
1550    /// otherwise idle, so multi-second protocol deadlines — MAC ACK
1551    /// timeouts, ping timeouts, repair timers — resolve in wall-clock
1552    /// milliseconds without changing any production code path.
1553    #[cfg(test)]
1554    async fn new_with_virtual_time(
1555        identity: Arc<MobileIdentity>,
1556        counter_store: Arc<MobileCounterStore>,
1557    ) -> Result<Arc<Self>, MobileMeshError> {
1558        Self::build(identity, counter_store, true).await
1559    }
1560
1561    async fn build(
1562        identity: Arc<MobileIdentity>,
1563        counter_store: Arc<MobileCounterStore>,
1564        virtual_time: bool,
1565    ) -> Result<Arc<Self>, MobileMeshError> {
1566        let (commands, command_rx) = mpsc::unbounded_channel();
1567        let wake = Arc::new(WakeSignal::new());
1568        let (outbound_tx, outbound) = std_mpsc::channel();
1569        let (event_tx, events) = std_mpsc::channel();
1570        let (advertisement_tx, advertisements) = std_mpsc::channel();
1571        let (peer_heard_tx, peer_heard) = std_mpsc::channel();
1572        let (chat_event_tx, chat_events) = std_mpsc::channel();
1573        let outbound_tx = NotifyingSender {
1574            tx: outbound_tx,
1575            wake: wake.clone(),
1576        };
1577        let event_tx = NotifyingSender {
1578            tx: event_tx,
1579            wake: wake.clone(),
1580        };
1581        let advertisement_tx = NotifyingSender {
1582            tx: advertisement_tx,
1583            wake: wake.clone(),
1584        };
1585        let peer_heard_tx = NotifyingSender {
1586            tx: peer_heard_tx,
1587            wake: wake.clone(),
1588        };
1589        let chat_event_tx = NotifyingSender {
1590            tx: chat_event_tx,
1591            wake: wake.clone(),
1592        };
1593        let (ready_tx, ready_rx) = oneshot::channel();
1594        let worker_identity = identity.take_for_session()?;
1595        let transmit_completions = Arc::new(BridgeTransmitCompletions::new());
1596        let worker_transmit_completions = transmit_completions.clone();
1597
1598        std::thread::Builder::new()
1599            .name("umsh-mobile-mesh".to_owned())
1600            // The whole 64-peer MAC lives inside the worker future, and the
1601            // future is polled (and moved during construction) on this
1602            // thread's stack. The platform default (512 KiB–2 MiB for
1603            // secondary threads) is not enough headroom for that.
1604            .stack_size(16 * 1024 * 1024)
1605            .spawn(move || {
1606                let mut builder = tokio::runtime::Builder::new_current_thread();
1607                builder.enable_time();
1608                #[cfg(test)]
1609                if virtual_time {
1610                    builder.start_paused(true);
1611                }
1612                #[cfg(not(test))]
1613                let _ = virtual_time;
1614                let runtime = match builder.build() {
1615                    Ok(runtime) => runtime,
1616                    Err(_) => {
1617                        let _ = ready_tx.send(Err(MobileMeshError::SessionUnavailable));
1618                        return;
1619                    }
1620                };
1621                let local = tokio::task::LocalSet::new();
1622                // Boxed so the future's state — which embeds the MAC and its
1623                // peer tables by value — lives on the heap rather than in
1624                // this thread's stack frame.
1625                local.block_on(
1626                    &runtime,
1627                    Box::pin(run_worker(
1628                        worker_identity,
1629                        SharedCounterStore(counter_store),
1630                        command_rx,
1631                        outbound_tx,
1632                        worker_transmit_completions,
1633                        event_tx,
1634                        advertisement_tx,
1635                        peer_heard_tx,
1636                        chat_event_tx,
1637                        ready_tx,
1638                    )),
1639                );
1640            })
1641            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1642
1643        ready_rx
1644            .await
1645            .map_err(|_| MobileMeshError::SessionUnavailable)??;
1646        Ok(Arc::new(Self {
1647            commands,
1648            outbound: Mutex::new(outbound),
1649            transmit_completions,
1650            events: Mutex::new(events),
1651            advertisements: Mutex::new(advertisements),
1652            peer_heard: Mutex::new(peer_heard),
1653            chat_events: Mutex::new(chat_events),
1654            pending_chat_events: Mutex::new(None),
1655            next_chat_batch_id: Mutex::new(1),
1656            next_operation_id: Mutex::new(1),
1657            wake,
1658        }))
1659    }
1660}
1661
1662impl Drop for MobileMeshSession {
1663    fn drop(&mut self) {
1664        self.transmit_completions.fail_all();
1665        let _ = self.commands.send(WorkerCommand::Shutdown);
1666    }
1667}
1668
1669/// Reduce a platform reading to the cell it discloses.
1670///
1671/// The integer path (`from_e7`) rather than the float one: it is exact at
1672/// every precision the format carries, so the cell a listener decodes is
1673/// the cell that was chosen, not its floating-point neighbour.
1674fn disclosed_cell(record: MobileMeshSharedLocationRecord) -> Result<NodeLocation, MobileMeshError> {
1675    if !(1..=MAX_PRECISION).contains(&record.precision_bytes)
1676        || !record.latitude_degrees.is_finite()
1677        || record.latitude_degrees.abs() > 90.0
1678        || !record.longitude_degrees.is_finite()
1679        || record.longitude_degrees.abs() > 180.0
1680    {
1681        return Err(MobileMeshError::InvalidLocation);
1682    }
1683    Ok(NodeLocation::from_e7(
1684        (record.latitude_degrees * 1e7).round() as i32,
1685        (record.longitude_degrees * 1e7).round() as i32,
1686        record.precision_bytes,
1687    ))
1688}
1689
1690/// The identity profile the phone's Identity Request responder serves:
1691/// the same role Chat / Mobile + Text messages statement the signed
1692/// advertisement makes, with the display name truncated identically and
1693/// the same disclosed location, so a node that asks and a node that
1694/// listens hear one description.
1695fn phone_identity_profile(
1696    public_key: PublicKey,
1697    name: Option<&str>,
1698    location: Option<NodeLocation>,
1699) -> NodeIdentityProfile {
1700    let mut profile = NodeIdentityProfile::new(
1701        public_key,
1702        NodeRole::Chat,
1703        NodeCapabilities::MOBILE | NodeCapabilities::TEXT_MESSAGES,
1704    );
1705    profile.name = name
1706        .map(|name| {
1707            let mut end = name.len().min(24);
1708            while !name.is_char_boundary(end) {
1709                end -= 1;
1710            }
1711            name[..end].to_owned()
1712        })
1713        .filter(|name| !name.is_empty());
1714    profile.location = location;
1715    profile
1716}
1717
1718/// Build the signed standalone node-identity bundle for this phone: role
1719/// Chat, capabilities Mobile + Text messages, optional display name
1720/// (truncated to the 24-byte wire limit on a character boundary), and the
1721/// disclosed location when one is being shared. The result is ROLE
1722/// through the trailing 64-byte signature, without the payload-type byte.
1723async fn build_signed_identity_bundle(
1724    signer: &SoftwareIdentity,
1725    name: Option<&str>,
1726    timestamp: Option<u32>,
1727    location: Option<NodeLocation>,
1728) -> Result<Vec<u8>, MobileMeshError> {
1729    let name = name
1730        .map(|name| {
1731            let mut end = name.len().min(24);
1732            while !name.is_char_boundary(end) {
1733                end -= 1;
1734            }
1735            name[..end].to_owned()
1736        })
1737        .filter(|name| !name.is_empty());
1738    let payload = NodeIdentityPayload {
1739        role: NodeRole::Chat,
1740        capabilities: NodeCapabilities::MOBILE | NodeCapabilities::TEXT_MESSAGES,
1741        name,
1742        location,
1743        altitude_m: None,
1744        timestamp,
1745        supported_regions: None,
1746        nonce: None,
1747        signature: None,
1748    };
1749    let mut buf = [0u8; 192];
1750    let len = payload
1751        .encode_for_signing(&mut buf)
1752        .map_err(|_| MobileMeshError::SendFailed)?;
1753    let signature = signer
1754        .sign(&buf[..len])
1755        .await
1756        .map_err(|_| MobileMeshError::SendFailed)?;
1757    let mut bundle = buf[..len].to_vec();
1758    bundle.extend_from_slice(&signature);
1759    Ok(bundle)
1760}
1761
1762async fn run_worker(
1763    identity: SoftwareIdentity,
1764    counter_store: SharedCounterStore,
1765    mut commands: mpsc::UnboundedReceiver<WorkerCommand>,
1766    outbound: NotifyingSender<MobileMeshOutboundFrameRecord>,
1767    transmit_completions: Arc<BridgeTransmitCompletions>,
1768    events: NotifyingSender<MobileMeshPingEventRecord>,
1769    advertisements: NotifyingSender<MobileMeshAdvertisementRecord>,
1770    peer_heard: NotifyingSender<MobileMeshPeerHeardRecord>,
1771    chat_events: NotifyingSender<MobileChatWorkerEvent>,
1772    ready: oneshot::Sender<Result<(), MobileMeshError>>,
1773) {
1774    let local_key = *identity.public_key();
1775    // The MAC takes ownership of the identity below; standalone bundle
1776    // signing (advertisements, QR bundles) uses this retained clone.
1777    let signer = identity.clone();
1778    let (inbound_tx, inbound_rx) = mpsc::unbounded_channel();
1779    let worker_completions = transmit_completions.clone();
1780    let radio = BridgeRadio {
1781        inbound: inbound_rx,
1782        outbound,
1783        completions: transmit_completions,
1784    };
1785    let mac = MobileMac::new(
1786        radio,
1787        CryptoEngine::new(SoftwareAes, SoftwareSha256),
1788        MobileClock::new(),
1789        rand::rng(),
1790        counter_store,
1791        RepeaterConfig::default(),
1792        OperatingPolicy::default(),
1793    );
1794    let cell = AsyncRefCell::new(mac);
1795    let handle = MacHandle::new(&cell);
1796    let identity_id = match handle.add_identity(identity).await {
1797        Ok(id) => id,
1798        Err(_) => {
1799            let _ = ready.send(Err(MobileMeshError::SessionUnavailable));
1800            return;
1801        }
1802    };
1803    if handle.load_persisted_counter(identity_id).await.is_err() {
1804        let _ = ready.send(Err(MobileMeshError::CounterPersistenceFailed));
1805        return;
1806    }
1807    // A stranger's authenticated unicast — an Identity Request reply, a
1808    // first contact — names its sender with a full 32-byte source key.
1809    // Auto-registration (unpinned, LRU-evictable) is what lets the MAC
1810    // verify such a frame at all; without it the reply to our own
1811    // Discover solicitation is dropped unheard. Device firmware runs
1812    // with the same setting.
1813    handle.set_auto_register_full_key_peers(true).await;
1814
1815    let mut host = Host::new(handle);
1816    let node = host.add_node(identity_id);
1817    // What this phone currently says about itself. The session starts
1818    // discoverable with no name and no location; the app pushes the
1819    // stored preferences via `set_discoverable` and
1820    // `set_advertised_location` right after install. Held here because
1821    // the responder profile is rebuilt whole whenever any of it changes,
1822    // and the advertisement arms read the location at send time.
1823    let mut discoverable = true;
1824    let mut responder_name: Option<String> = None;
1825    let mut advertised_location: Option<NodeLocation> = None;
1826    node.enable_identity_responder_default(phone_identity_profile(
1827        local_key,
1828        responder_name.as_deref(),
1829        advertised_location,
1830    ));
1831    // Held outside the chat state: rejecting a batch rebuilds the reducer,
1832    // and the channels the platform registered must outlive that.
1833    let channel_registry = Rc::new(RefCell::new(ChannelRegistry::default()));
1834    let mut chat = MobileChatState::new(local_key, channel_registry.clone());
1835    // Registered before every other receive handler: dispatch stops at the
1836    // first handler that claims a packet, and presence is true of packets
1837    // that something else goes on to claim. It never claims one itself.
1838    let peer_heard_events = peer_heard.clone();
1839    let peer_heard_subscription = node.on_receive(move |packet| {
1840        let _ = peer_heard_events.send(MobileMeshPeerHeardRecord {
1841            peer_address: packet.from_key().map(|peer| encode_peer_address(&peer)),
1842            node_hint: packet.from_hint().map(|hint| hint.0.to_vec()),
1843            source_authenticated: packet.source_authenticated(),
1844        });
1845        false
1846    });
1847    let inbound_text = Rc::new(RefCell::new(Vec::<InboundText>::new()));
1848    let inbound_text_callback = inbound_text.clone();
1849    let text_channels = channel_registry.clone();
1850    let echo_events = chat_events.clone();
1851    let text_subscription = node.on_receive(move |packet| {
1852        if packet.payload_type() != PayloadType::TextMessage {
1853            return false;
1854        }
1855        // Our own multicast, relayed back to us. Every group send carries our
1856        // full source address, so a repeater's copy arrives naming us — but
1857        // it is the message we already have, not a second one, and the
1858        // transcript must not show it twice.
1859        //
1860        // It is still evidence: something out there received our frame and
1861        // forwarded it, which is the only reachability signal a multicast
1862        // ever produces. Claim it so nothing else interprets it, and report
1863        // how far it travelled.
1864        if packet.from_key() == Some(local_key) {
1865            let hops = packet
1866                .flood_hops()
1867                .map(|hops| hops.accumulated())
1868                .unwrap_or(0);
1869            let _ = echo_events.send(MobileChatWorkerEvent::Diagnostic(format!(
1870                "own multicast relayed back after {hops} hop(s)"
1871            )));
1872            return true;
1873        }
1874        // A channel frame names its channel by the key that authenticated it,
1875        // so the tag is derived from that key rather than looked up by the
1876        // two-byte identifier the frame carried — distinct keys may share an
1877        // identifier, and only the key that decrypted the frame is the truth.
1878        let channel_tag = packet.channel().map(|channel| {
1879            (
1880                crate::channel_tag(channel.key()),
1881                text_channels
1882                    .borrow()
1883                    .contains(&crate::channel_tag(channel.key())),
1884            )
1885        });
1886        // The same rule read from the receiving end: emergency traffic that is
1887        // not readable by every node in range, or that does not name its
1888        // sender outright, is not accepted at all. A frame that fails either
1889        // test is dropped rather than shown unmarked — a message the reader
1890        // would act on in an emergency must not arrive with its origin or its
1891        // reach in question.
1892        //
1893        // Both checks live here, at the chat layer, rather than under the MAC:
1894        // the requirement the spec states is about chat messages, and the MAC
1895        // is deliberately incurious about what it carries.
1896        if let Some((tag, _)) = channel_tag
1897            && tag == crate::emergency_channel_tag()
1898            && (packet.encrypted() || packet.from_key().is_none())
1899        {
1900            let reason = if packet.encrypted() {
1901                "encrypted"
1902            } else {
1903                "missing its full source key"
1904            };
1905            let _ = echo_events.send(MobileChatWorkerEvent::Diagnostic(format!(
1906                "dropped an emergency-channel text frame: {reason}"
1907            )));
1908            return false;
1909        }
1910        let source = match (packet.packet_family(), channel_tag) {
1911            (PacketFamily::Unicast, _) => match packet.from_key() {
1912                Some(peer) => InboundTextSource::Direct { peer },
1913                None => return false,
1914            },
1915            // Membership is what the channel MIC authenticates; the hint is
1916            // the only sender identity a multicast frame must carry.
1917            (PacketFamily::Multicast, Some((channel, true))) => match packet.from_hint() {
1918                Some(hint) => InboundTextSource::ChannelGroup {
1919                    channel,
1920                    hint,
1921                    full_key: packet.from_key(),
1922                },
1923                None => return false,
1924            },
1925            // Without a full key there is nobody to attribute the message to,
1926            // and nobody to answer a repair request to.
1927            (PacketFamily::BlindUnicast, Some((channel, true))) => match packet.from_key() {
1928                Some(peer) => InboundTextSource::ChannelDirect { channel, peer },
1929                None => return false,
1930            },
1931            _ => return false,
1932        };
1933        inbound_text_callback.borrow_mut().push(InboundText {
1934            source,
1935            payload: packet.payload().to_vec(),
1936            received_at_ms: packet.received_at_ms(),
1937            rx: MobileChatRxMetadataRecord {
1938                rssi_dbm: packet.rssi(),
1939                snr_centibels: packet.snr().map(|snr| snr.as_centibels()),
1940                lqi: packet.lqi().map(|lqi| lqi.get()),
1941                hop_count: packet.flood_hops().map(|hops| hops.accumulated()),
1942                route_hints: packet
1943                    .trace_route_hops()
1944                    .map(|hop| hop.0.to_vec())
1945                    .collect(),
1946                source_authenticated: packet.source_authenticated(),
1947            },
1948        });
1949        true
1950    });
1951    let advertisement_events = advertisements.clone();
1952    let advertisement_subscription = node.on_receive(move |packet| {
1953        if packet.payload_type() != PayloadType::NodeIdentity {
1954            return false;
1955        }
1956        // Hint-only sources cannot name a key to verify the bundle's
1957        // signature against, so they are not surfaced at all.
1958        let Some(peer) = packet.from_key() else {
1959            return false;
1960        };
1961        let _ = advertisement_events.send(MobileMeshAdvertisementRecord {
1962            peer_address: encode_peer_address(&peer),
1963            payload: packet.payload().to_vec(),
1964            source_authenticated: packet.source_authenticated(),
1965        });
1966        true
1967    });
1968    let mut in_flight_chat = Vec::<InFlightChatTransmission>::new();
1969    // How each channel member was last reached, so an identity request can be
1970    // routed by evidence rather than flooded at the default budget.
1971    let mut member_routes = BTreeMap::<(ChannelTag, [u8; 3]), MemberRoute>::new();
1972    let mut chat_pipeline_ready = BTreeSet::<[u8; 32]>::new();
1973    let mut pending_chat_transmissions = VecDeque::<umsh_text::engine::Transmission>::new();
1974    let pending = Rc::new(RefCell::new(BTreeMap::<[u8; 32], u64>::new()));
1975    let pong_pending = pending.clone();
1976    let pong_events = events.clone();
1977    let pong_subscription = node.on_pong_with_metadata(move |peer, metadata| {
1978        if let Some(operation_id) = pong_pending.borrow_mut().remove(&peer.0) {
1979            let _ = pong_events.send(MobileMeshPingEventRecord {
1980                operation_id,
1981                outcome: MobileMeshPingOutcome::Reply,
1982                round_trip_milliseconds: Some(metadata.round_trip_ms),
1983                hop_count: metadata.hop_count,
1984                route_hints: metadata
1985                    .route_hints
1986                    .iter()
1987                    .map(|hint| hint.0.to_vec())
1988                    .collect(),
1989                rssi_dbm: metadata.rssi_dbm,
1990                snr_centibels: metadata.snr_centibels,
1991                lqi: metadata.lqi,
1992            });
1993        }
1994    });
1995    let timeout_pending = pending.clone();
1996    let timeout_events = events.clone();
1997    let timeout_subscription = node.on_ping_timeout(move |peer| {
1998        if let Some(operation_id) = timeout_pending.borrow_mut().remove(&peer.0) {
1999            let _ = timeout_events.send(MobileMeshPingEventRecord {
2000                operation_id,
2001                outcome: MobileMeshPingOutcome::TimedOut,
2002                round_trip_milliseconds: None,
2003                hop_count: None,
2004                route_hints: Vec::new(),
2005                rssi_dbm: None,
2006                snr_centibels: None,
2007                lqi: None,
2008            });
2009        }
2010    });
2011    let _subscriptions = (
2012        pong_subscription,
2013        timeout_subscription,
2014        peer_heard_subscription,
2015        text_subscription,
2016        advertisement_subscription,
2017    );
2018    let _ = ready.send(Ok(()));
2019    let mut protocol_timeout_tick = tokio::time::interval(Duration::from_millis(50));
2020    protocol_timeout_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2021
2022    // The worker runs as two sibling loops polled by one outer select.
2023    //
2024    // `Radio::transmit` awaits the device's physical TX completion while
2025    // `MacHandle` holds the coordinator borrow, so the pump must keep being
2026    // polled while a command arm waits on that borrow — a single select
2027    // whose arm bodies suspend the task would deadlock: the arm waits on
2028    // the borrow, and the pump future that owns it is never re-polled to
2029    // release it. As sibling futures of the outer select, the pump makes
2030    // progress whenever the command loop is waiting.
2031    let inbound_ready = tokio::sync::Notify::new();
2032    let timeout_servicer = host.protocol_timeout_servicer();
2033
2034    let pump_loop = async {
2035        loop {
2036            if host.pump_once().await.is_err() {
2037                return;
2038            }
2039            if !inbound_text.borrow().is_empty() {
2040                inbound_ready.notify_one();
2041            }
2042        }
2043    };
2044
2045    let command_loop = async {
2046        loop {
2047            tokio::select! {
2048                biased;
2049                command = commands.recv() => {
2050                    match command {
2051                        Some(WorkerCommand::RegisterPeers { peers, response }) => {
2052                            let mut result = Ok(());
2053                            for peer in peers {
2054                                if node.peer(peer).await.is_err() {
2055                                    result = Err(MobileMeshError::SendFailed);
2056                                    break;
2057                                }
2058                            }
2059                            let _ = response.send(result);
2060                        }
2061                        Some(WorkerCommand::RemovePeers { peers, response }) => {
2062                            for peer in peers {
2063                                // Not-found is success: the peer is absent
2064                                // either way.
2065                                let _ = node.remove_peer(&peer).await;
2066                            }
2067                            let _ = response.send(Ok(()));
2068                        }
2069                        Some(WorkerCommand::RegisterChannels { keys, response }) => {
2070                            let mut result = Ok(());
2071                            for key in keys {
2072                                // Named and private channels are the same
2073                                // thing here: the app already holds the
2074                                // derived key either way.
2075                                if node.join(&umsh_node::Channel::private(key, "")).await.is_err() {
2076                                    result = Err(MobileMeshError::ChannelCapacity);
2077                                    break;
2078                                }
2079                                channel_registry
2080                                    .borrow_mut()
2081                                    .register(crate::channel_tag(&key), key);
2082                            }
2083                            let _ = response.send(result);
2084                        }
2085                        Some(WorkerCommand::RemoveChannels { keys, response }) => {
2086                            for key in keys {
2087                                // Not-joined is success, as for peers.
2088                                let _ = node.leave(&umsh_node::Channel::private(key, "")).await;
2089                                channel_registry
2090                                    .borrow_mut()
2091                                    .remove(&crate::channel_tag(&key));
2092                            }
2093                            let _ = response.send(Ok(()));
2094                        }
2095                        Some(WorkerCommand::Ping { operation_id, peer, timeout_ms }) => {
2096                            if pending.borrow().contains_key(&peer.0) {
2097                                emit_ping_failure(&events, operation_id);
2098                                continue;
2099                            }
2100                            let result = match node.peer(peer).await {
2101                                Ok(connection) => connection
2102                                    .ping(
2103                                        6,
2104                                        &SendOptions::default()
2105                                            .with_flood_hops(5)
2106                                            .with_trace_route()
2107                                            .with_mic_size(umsh_node::PING_MIC_SIZE),
2108                                        timeout_ms,
2109                                    )
2110                                    .await
2111                                    .map(|_| ())
2112                                    .map_err(|_| MobileMeshError::SendFailed),
2113                                Err(_) => Err(MobileMeshError::SendFailed),
2114                            };
2115                            if result.is_ok() {
2116                                pending.borrow_mut().insert(peer.0, operation_id);
2117                                // This write is caused by a real authenticated send. It is
2118                                // deliberately not performed during startup. Do not move it
2119                                // into session construction: reboot loops must remain read-only.
2120                                if handle.service_counter_persistence().await.is_err() {
2121                                    emit_ping_failure(&events, operation_id);
2122                                    return;
2123                                }
2124                            }
2125                            if result.is_err() {
2126                                emit_ping_failure(&events, operation_id);
2127                            }
2128                        }
2129                        Some(WorkerCommand::Advertise { name, timestamp, scheduled, response }) => {
2130                            let result = match build_signed_identity_bundle(
2131                                &signer,
2132                                name.as_deref(),
2133                                timestamp,
2134                                advertised_location,
2135                            )
2136                            .await
2137                            {
2138                                Ok(bundle) => {
2139                                    let mut frame = Vec::with_capacity(bundle.len() + 1);
2140                                    frame.push(PayloadType::NodeIdentity as u8);
2141                                    frame.extend_from_slice(&bundle);
2142                                    // Full source either way, so the detached
2143                                    // signature is checkable.
2144                                    let options = SendOptions::default().with_full_source();
2145                                    let options = if scheduled {
2146                                        // No flood budget and no trace: a
2147                                        // restatement on a timer belongs to
2148                                        // the neighbours who can hear it.
2149                                        options.no_flood()
2150                                    } else {
2151                                        // Trace route so a listener learns a
2152                                        // path back to this phone from the
2153                                        // same frame.
2154                                        options.with_trace_route()
2155                                    };
2156                                    node.send_all(&frame, &options)
2157                                        .await
2158                                        .map(|_| ())
2159                                        .map_err(|_| MobileMeshError::SendFailed)
2160                                }
2161                                Err(error) => Err(error),
2162                            };
2163                            if result.is_ok()
2164                                && handle.service_counter_persistence().await.is_err()
2165                            {
2166                                let _ = response.send(Err(MobileMeshError::SendFailed));
2167                                return;
2168                            }
2169                            let _ = response.send(result);
2170                        }
2171                        Some(WorkerCommand::Beacon { response }) => {
2172                            // Trace route to learn the path, trace signal to
2173                            // learn what that path costs.
2174                            let result = node
2175                                .send_all(
2176                                    &[],
2177                                    &SendOptions::default()
2178                                        .with_flood_hops(BEACON_FLOOD_HOPS)
2179                                        .with_trace_route()
2180                                        .with_trace_signal(),
2181                                )
2182                                .await
2183                                .map(|_| ())
2184                                .map_err(|_| MobileMeshError::SendFailed);
2185                            if result.is_ok()
2186                                && handle.service_counter_persistence().await.is_err()
2187                            {
2188                                let _ = response.send(Err(MobileMeshError::SendFailed));
2189                                return;
2190                            }
2191                            let _ = response.send(result);
2192                        }
2193                        Some(WorkerCommand::SignIdentityBundle { name, timestamp, response }) => {
2194                            // Never the location: this bundle outlives the
2195                            // moment — pasted into messages, printed as a
2196                            // QR — and a position frozen into it goes
2197                            // stale and then travels wherever it does.
2198                            let result = build_signed_identity_bundle(
2199                                &signer,
2200                                name.as_deref(),
2201                                timestamp,
2202                                None,
2203                            )
2204                            .await;
2205                            let _ = response.send(result);
2206                        }
2207                        Some(WorkerCommand::RequestIdentity { peer, response }) => {
2208                            let result = match node.peer(peer).await {
2209                                Ok(connection) => connection
2210                                    .request_identity(
2211                                        &SendOptions::default()
2212                                            .with_flood_hops(5)
2213                                            .with_ack_requested(false),
2214                                    )
2215                                    .await
2216                                    .map(|_| ())
2217                                    .map_err(|_| MobileMeshError::SendFailed),
2218                                Err(_) => Err(MobileMeshError::SendFailed),
2219                            };
2220                            // A real authenticated send advances the frame counter;
2221                            // persist it before acknowledging, as the ping/advertise
2222                            // paths do.
2223                            if result.is_ok()
2224                                && handle.service_counter_persistence().await.is_err()
2225                            {
2226                                let _ = response.send(Err(MobileMeshError::SendFailed));
2227                                return;
2228                            }
2229                            let _ = response.send(result);
2230                        }
2231                        Some(WorkerCommand::RequestIdentityByHint {
2232                            conversation_address,
2233                            hint,
2234                            response,
2235                        }) => {
2236                            let channel = chat
2237                                .parse_conversation_address(&conversation_address)
2238                                .and_then(|conversation| match conversation {
2239                                    ConversationKey::ChannelGroup { channel } => Some(channel),
2240                                    _ => None,
2241                                });
2242                            let result = match channel {
2243                                Some(channel) => {
2244                                    let route = member_routes.get(&(channel, hint.0)).cloned();
2245                                    let mut nonce_bytes = [0u8; 4];
2246                                    handle.fill_random(&mut nonce_bytes).await;
2247                                    request_identity_over_channel(
2248                                        &node,
2249                                        &channel_registry,
2250                                        channel,
2251                                        hint,
2252                                        u32::from_be_bytes(nonce_bytes),
2253                                        route,
2254                                    )
2255                                    .await
2256                                }
2257                                None => Err(MobileMeshError::UnknownConversation),
2258                            };
2259                            if result.is_ok()
2260                                && handle.service_counter_persistence().await.is_err()
2261                            {
2262                                let _ = response.send(Err(MobileMeshError::SendFailed));
2263                                return;
2264                            }
2265                            let _ = response.send(result);
2266                        }
2267                        Some(WorkerCommand::SetChatDisplayName { name, response }) => {
2268                            chat.engine.set_local_handle(&name);
2269                            let _ = response.send(());
2270                        }
2271                        Some(WorkerCommand::SetDiscoverable { enabled, name, response }) => {
2272                            discoverable = enabled;
2273                            responder_name = name;
2274                            if discoverable {
2275                                node.enable_identity_responder_default(phone_identity_profile(
2276                                    local_key,
2277                                    responder_name.as_deref(),
2278                                    advertised_location,
2279                                ));
2280                            } else {
2281                                node.disable_identity_responder();
2282                            }
2283                            let _ = response.send(());
2284                        }
2285                        Some(WorkerCommand::SetAdvertisedLocation { location, response }) => {
2286                            advertised_location = location;
2287                            // The installed profile is a copy, so a live
2288                            // responder is reinstalled to serve the new
2289                            // position. Not discoverable means not
2290                            // installed — nothing to refresh.
2291                            if discoverable {
2292                                node.enable_identity_responder_default(phone_identity_profile(
2293                                    local_key,
2294                                    responder_name.as_deref(),
2295                                    advertised_location,
2296                                ));
2297                            }
2298                            let _ = response.send(());
2299                        }
2300                        Some(WorkerCommand::DiscoverIdentities {
2301                            role_code,
2302                            capability_bits,
2303                            response,
2304                        }) => {
2305                            let result = async {
2306                                let mut builder = umsh_node::mac_command::IdentityRequestBuilder::new();
2307                                let mut nonce_bytes = [0u8; 4];
2308                                handle.fill_random(&mut nonce_bytes).await;
2309                                builder = builder
2310                                    .nonce(u32::from_be_bytes(nonce_bytes))
2311                                    .map_err(|_| MobileMeshError::SendFailed)?;
2312                                if let Some(role) = role_code {
2313                                    builder = builder
2314                                        .filter_role(NodeRole::from_byte(role))
2315                                        .map_err(|_| MobileMeshError::SendFailed)?;
2316                                }
2317                                // A broadcast request must carry at least one
2318                                // filter option. An unrestricted ask carries a
2319                                // zero-bit capability filter, which every node
2320                                // satisfies.
2321                                let capability_bits = capability_bits
2322                                    .or(if role_code.is_none() { Some(0) } else { None });
2323                                if let Some(bits) = capability_bits {
2324                                    builder = builder
2325                                        .filter_caps(NodeCapabilities::from_bits_truncate(bits))
2326                                        .map_err(|_| MobileMeshError::SendFailed)?;
2327                                }
2328                                let options_block = builder.build();
2329                                let cmd = umsh_node::MacCommand::IdentityRequest {
2330                                    options: &options_block,
2331                                };
2332                                let mut frame = [0u8; 128];
2333                                frame[0] = PayloadType::MacCommand as u8;
2334                                let length = umsh_node::mac_command::encode(&cmd, &mut frame[1..])
2335                                    .map_err(|_| MobileMeshError::SendFailed)?
2336                                    + 1;
2337                                // Zero-hop by design: no flood budget, so
2338                                // repeaters never carry the solicitation.
2339                                // Full source lets a stranger unicast back.
2340                                node.send_all(
2341                                    &frame[..length],
2342                                    &SendOptions::default().with_full_source().no_flood(),
2343                                )
2344                                .await
2345                                .map(|_| ())
2346                                .map_err(|_| MobileMeshError::SendFailed)
2347                            }
2348                            .await;
2349                            if result.is_ok()
2350                                && handle.service_counter_persistence().await.is_err()
2351                            {
2352                                let _ = response.send(Err(MobileMeshError::SendFailed));
2353                                return;
2354                            }
2355                            let _ = response.send(result);
2356                        }
2357                        Some(WorkerCommand::PeerRoute { peer, response }) => {
2358                            let _ = response.send(node.peer_route(&peer).await.into());
2359                        }
2360                        Some(WorkerCommand::ClearPeerRoute { peer, response }) => {
2361                            let _ = response.send(node.clear_peer_route(&peer).await);
2362                        }
2363                        Some(WorkerCommand::RestoreChat { checkpoints, response }) => {
2364                            chat.restore(&checkpoints, handle.now_ms().await);
2365                            let _ = response.send(());
2366                        }
2367                        Some(WorkerCommand::ComposeChat {
2368                            conversation_address,
2369                            client_token,
2370                            request,
2371                            response,
2372                        }) => {
2373                            // Rejecting a persisted batch rebuilds the reducer from
2374                            // durable checkpoints. Keep that recovery operation
2375                            // unambiguous by allowing only one uncommitted compose.
2376                            let conversation =
2377                                chat.parse_conversation_address(&conversation_address);
2378                            let result = if !chat.pending_batches.is_empty() {
2379                                Err(MobileMeshError::OperationInProgress)
2380                            } else if let Some(conversation) = conversation {
2381                                let now_ms = handle.now_ms().await;
2382                                let composed = match &request {
2383                                    ChatComposeRequest::Text { body } => {
2384                                        chat.compose_text(conversation, client_token, body, now_ms)
2385                                    }
2386                                    ChatComposeRequest::Edit { original, body } => chat.compose_edit(
2387                                        conversation,
2388                                        client_token,
2389                                        original,
2390                                        body,
2391                                        now_ms,
2392                                    ),
2393                                    ChatComposeRequest::Delete { original } => chat.compose_delete(
2394                                        conversation,
2395                                        client_token,
2396                                        original,
2397                                        now_ms,
2398                                    ),
2399                                    ChatComposeRequest::Reaction { target, body } => chat
2400                                        .compose_reaction(
2401                                            conversation,
2402                                            client_token,
2403                                            target,
2404                                            body,
2405                                            now_ms,
2406                                        ),
2407                                };
2408                                match composed {
2409                                    Ok(composed) => {
2410                                        for delivery in composed.deliveries {
2411                                            let _ = chat_events.send(
2412                                                MobileChatWorkerEvent::Delivery(delivery),
2413                                            );
2414                                        }
2415                                        for diagnostic in composed.diagnostics {
2416                                            let _ = chat_events.send(
2417                                                MobileChatWorkerEvent::Diagnostic(diagnostic),
2418                                            );
2419                                        }
2420                                        Ok(composed.record)
2421                                    }
2422                                    Err(()) => Err(MobileMeshError::ChatComposeFailed),
2423                                }
2424                            } else {
2425                                // Either the address is malformed, or it names
2426                                // a channel this session does not hold a key
2427                                // for — from here those are the same thing.
2428                                Err(MobileMeshError::UnknownConversation)
2429                            };
2430                            let _ = response.send(result);
2431                        }
2432                        Some(WorkerCommand::CommitChatBatch { batch_id, response }) => {
2433                            let result = match chat.pending_batches.remove(&batch_id) {
2434                                Some(batch) => {
2435                                    let now_ms = handle.now_ms().await;
2436                                    let sent = queue_chat_transmissions(
2437                                        &node,
2438                                        batch.transmissions,
2439                                        &mut pending_chat_transmissions,
2440                                        &mut in_flight_chat,
2441                                        &chat_pipeline_ready,
2442                                        &channel_registry,
2443                                        &mut chat,
2444                                        now_ms,
2445                                    )
2446                                    .await;
2447                                    publish_chat_drain(chat.drain(), &chat_events);
2448                                    if sent > 0
2449                                        && handle.service_counter_persistence().await.is_err()
2450                                    {
2451                                        Err(MobileMeshError::CounterPersistenceFailed)
2452                                    } else {
2453                                        Ok(())
2454                                    }
2455                                }
2456                                None => Err(MobileMeshError::ChatBatchMissing),
2457                            };
2458                            let fatal = result == Err(MobileMeshError::CounterPersistenceFailed);
2459                            let _ = response.send(result);
2460                            if fatal {
2461                                return;
2462                            }
2463                        }
2464                        Some(WorkerCommand::RejectChatBatch {
2465                            batch_id,
2466                            checkpoints,
2467                            response,
2468                        }) => {
2469                            let result = match chat.pending_batches.remove(&batch_id) {
2470                                Some(batch) => {
2471                                    for transmission in batch.transmissions {
2472                                        chat.engine.transmit_update(
2473                                            transmission.transmission_id,
2474                                            DeliveryState::Failed,
2475                                            handle.now_ms().await,
2476                                        );
2477                                    }
2478                                    publish_chat_drain(chat.drain(), &chat_events);
2479                                    chat = MobileChatState::new(
2480                                        local_key,
2481                                        channel_registry.clone(),
2482                                    );
2483                                    for diagnostic in
2484                                        chat.restore(&checkpoints, handle.now_ms().await)
2485                                    {
2486                                        let _ = chat_events.send(
2487                                            MobileChatWorkerEvent::Diagnostic(diagnostic),
2488                                        );
2489                                    }
2490                                    Ok(())
2491                                }
2492                                None => Err(MobileMeshError::ChatBatchMissing),
2493                            };
2494                            let _ = response.send(result);
2495                        }
2496                        Some(WorkerCommand::ChatArchiveResult {
2497                            request_id,
2498                            kind,
2499                            payload,
2500                        }) => {
2501                            let now_ms = handle.now_ms().await;
2502                            match kind {
2503                                MobileChatArchiveResultKind::Found => chat.engine.archive_result(
2504                                    request_id,
2505                                    ArchiveResult::Found { payload: &payload },
2506                                    now_ms,
2507                                ),
2508                                MobileChatArchiveResultKind::Deleted => chat.engine.archive_result(
2509                                    request_id,
2510                                    ArchiveResult::Deleted,
2511                                    now_ms,
2512                                ),
2513                                MobileChatArchiveResultKind::Evicted => chat.engine.archive_result(
2514                                    request_id,
2515                                    ArchiveResult::Evicted,
2516                                    now_ms,
2517                                ),
2518                                MobileChatArchiveResultKind::Unknown => chat.engine.archive_result(
2519                                    request_id,
2520                                    ArchiveResult::Unknown,
2521                                    now_ms,
2522                                ),
2523                            }
2524                            let drain = chat.drain();
2525                            let transmissions = drain.transmissions.clone();
2526                            publish_chat_drain(drain, &chat_events);
2527                            if !transmissions.is_empty() || !pending_chat_transmissions.is_empty() {
2528                                let sent = queue_chat_transmissions(
2529                                    &node,
2530                                    transmissions,
2531                                    &mut pending_chat_transmissions,
2532                                    &mut in_flight_chat,
2533                                    &chat_pipeline_ready,
2534                                    &channel_registry,
2535                                    &mut chat,
2536                                    now_ms,
2537                                )
2538                                .await;
2539                                publish_chat_drain(chat.drain(), &chat_events);
2540                                if sent > 0 && handle.service_counter_persistence().await.is_err() {
2541                                    return;
2542                                }
2543                            }
2544                        }
2545                        Some(WorkerCommand::FailOutboundTransmissions) => {
2546                            let now_ms = handle.now_ms().await;
2547                            for transmission in pending_chat_transmissions.drain(..) {
2548                                chat.engine.transmit_update(
2549                                    transmission.transmission_id,
2550                                    DeliveryState::Failed,
2551                                    now_ms,
2552                                );
2553                            }
2554                            for transmission in in_flight_chat.drain(..) {
2555                                if let Some(receipt) = transmission.ticket.receipt() {
2556                                    let _ = handle.cancel_pending_ack(identity_id, receipt).await;
2557                                }
2558                                chat.engine.transmit_update(
2559                                    transmission.transmission_id,
2560                                    DeliveryState::Failed,
2561                                    now_ms,
2562                                );
2563                            }
2564                            publish_chat_drain(chat.drain(), &chat_events);
2565                            // Every frame the failure covered is now cancelled;
2566                            // new transmissions may reach the platform again.
2567                            worker_completions.clear_poison();
2568                        }
2569                        Some(WorkerCommand::Receive(record)) => {
2570                            let _ = inbound_tx.send(InboundFrame { record });
2571                        }
2572                        Some(WorkerCommand::Shutdown) | None => return,
2573                    }
2574                }
2575                _ = inbound_ready.notified() => {
2576                    let received = inbound_text.borrow_mut().drain(..).collect::<Vec<_>>();
2577                    for text in received {
2578                        let received_at_ms = match text.received_at_ms {
2579                            Some(value) => value,
2580                            None => handle.now_ms().await,
2581                        };
2582                        // The envelope's sender is what the engine keys a
2583                        // stream by. A multicast member is always the claimed
2584                        // hint, with the full key passed alongside: naming the
2585                        // key here instead would split one member into two
2586                        // streams the moment a frame omitted it.
2587                        let (envelope, sender_full_key) = match text.source {
2588                            InboundTextSource::Direct { peer } => (
2589                                Envelope {
2590                                    path: DeliveryPath::Unicast,
2591                                    conversation: ConversationKey::Direct { peer },
2592                                    sender: SenderScope::Peer(peer),
2593                                },
2594                                Some(peer),
2595                            ),
2596                            InboundTextSource::ChannelGroup {
2597                                channel,
2598                                hint,
2599                                full_key,
2600                            } => {
2601                                if let Some(peer) = full_key {
2602                                    if let Some(resolution) =
2603                                        chat.resolve_member(channel, hint, peer)
2604                                    {
2605                                        let _ = chat_events.send(
2606                                            MobileChatWorkerEvent::SenderResolution(resolution),
2607                                        );
2608                                    }
2609                                }
2610                                remember_member_route(
2611                                    &mut member_routes,
2612                                    channel,
2613                                    hint,
2614                                    &text.rx,
2615                                );
2616                                (
2617                                    Envelope {
2618                                        path: DeliveryPath::Multicast,
2619                                        conversation: ConversationKey::ChannelGroup { channel },
2620                                        sender: SenderScope::ClaimedMember(hint),
2621                                    },
2622                                    full_key,
2623                                )
2624                            }
2625                            InboundTextSource::ChannelDirect { channel, peer } => (
2626                                Envelope {
2627                                    path: DeliveryPath::BlindUnicast,
2628                                    conversation: ConversationKey::ChannelDirect { channel, peer },
2629                                    sender: SenderScope::Peer(peer),
2630                                },
2631                                Some(peer),
2632                            ),
2633                        };
2634                        let _ = chat.engine.receive(
2635                            &envelope,
2636                            sender_full_key,
2637                            &text.payload,
2638                            received_at_ms,
2639                        );
2640                        let mut drain = chat.drain();
2641                        // This drain belongs to exactly one frame, so the
2642                        // records it produced are the ones that frame caused.
2643                        attach_rx_metadata(&mut drain.mutations, &text.rx);
2644                        let transmissions = drain.transmissions.clone();
2645                        publish_chat_drain(drain, &chat_events);
2646                        if !transmissions.is_empty() || !pending_chat_transmissions.is_empty() {
2647                            let sent = queue_chat_transmissions(
2648                                &node,
2649                                transmissions,
2650                                &mut pending_chat_transmissions,
2651                                &mut in_flight_chat,
2652                                &chat_pipeline_ready,
2653                                &channel_registry,
2654                                &mut chat,
2655                                received_at_ms,
2656                            )
2657                            .await;
2658                            publish_chat_drain(chat.drain(), &chat_events);
2659                            if sent > 0 && handle.service_counter_persistence().await.is_err() {
2660                                return;
2661                            }
2662                        }
2663                    }
2664                }
2665                _ = protocol_timeout_tick.tick() => {
2666                    timeout_servicer.service().await;
2667                    let now_ms = handle.now_ms().await;
2668                    chat.engine.tick(now_ms);
2669                    service_chat_tickets(
2670                        &mut chat,
2671                        &mut in_flight_chat,
2672                        &mut chat_pipeline_ready,
2673                        &chat_events,
2674                        pending_chat_transmissions.len(),
2675                        now_ms,
2676                    );
2677                    let drain = chat.drain();
2678                    let transmissions = drain.transmissions.clone();
2679                    publish_chat_drain(drain, &chat_events);
2680                    if !transmissions.is_empty() || !pending_chat_transmissions.is_empty() {
2681                        let sent = queue_chat_transmissions(
2682                            &node,
2683                            transmissions,
2684                            &mut pending_chat_transmissions,
2685                            &mut in_flight_chat,
2686                            &chat_pipeline_ready,
2687                            &channel_registry,
2688                            &mut chat,
2689                            now_ms,
2690                        )
2691                        .await;
2692                        publish_chat_drain(chat.drain(), &chat_events);
2693                        if sent > 0 && handle.service_counter_persistence().await.is_err() {
2694                            return;
2695                        }
2696                    }
2697                }
2698            }
2699        }
2700    };
2701
2702    // Either loop ending (pump error, shutdown command, fatal persistence
2703    // failure) ends the session.
2704    tokio::select! {
2705        _ = pump_loop => {}
2706        _ = command_loop => {}
2707    }
2708}
2709
2710async fn queue_chat_transmissions<M: MacBackend>(
2711    node: &LocalNode<M>,
2712    transmissions: Vec<umsh_text::engine::Transmission>,
2713    pending: &mut VecDeque<umsh_text::engine::Transmission>,
2714    in_flight: &mut Vec<InFlightChatTransmission>,
2715    pipeline_ready: &BTreeSet<[u8; 32]>,
2716    channels: &Rc<RefCell<ChannelRegistry>>,
2717    chat: &mut MobileChatState,
2718    now_ms: u64,
2719) -> usize {
2720    pending.extend(transmissions);
2721    // Keep a bounded pipeline aligned with the device's target-selected
2722    // TX queue. The durable pending queue below handles messages larger than
2723    // this window without imposing the mobile RAM choice on embedded MACs.
2724    if in_flight.len() >= MOBILE_CHAT_TRANSMIT_WINDOW {
2725        return 0;
2726    }
2727    let mut queued = 0;
2728    while let Some(transmission) = pending.pop_front() {
2729        let gate_peer = match transmission.destination {
2730            Destination::Peer(peer) => Some(peer),
2731            // Multicast is unaddressed and blind unicast carries no ACK, so
2732            // neither has a peer whose pipeline could be confirmed.
2733            Destination::Channel(_) | Destination::ChannelPeer { .. } => None,
2734        };
2735        if let Some(peer) = gate_peer {
2736            if !pipeline_ready.contains(&peer.0)
2737                && in_flight.iter().any(|entry| entry.gate_peer == Some(peer))
2738            {
2739                // First contact may require counter synchronization. Confirm
2740                // one authenticated frame before opening this peer's full
2741                // pipeline.
2742                pending.push_front(transmission);
2743                break;
2744            }
2745        }
2746        let mut payload = Vec::with_capacity(transmission.payload.len() + 1);
2747        payload.push(PayloadType::TextMessage as u8);
2748        payload.extend_from_slice(transmission.payload.as_slice());
2749        let sent = match transmission.destination {
2750            Destination::Peer(peer) => match node.peer(peer).await {
2751                Ok(connection) => {
2752                    connection
2753                        .send(&payload, &SendOptions::default().with_ack_requested(true))
2754                        .await
2755                }
2756                Err(_) => {
2757                    chat.engine.transmit_update(
2758                        transmission.transmission_id,
2759                        DeliveryState::Failed,
2760                        now_ms,
2761                    );
2762                    continue;
2763                }
2764            },
2765            Destination::Channel(channel) => {
2766                let Some(bound) = bound_channel(node, channels, &channel) else {
2767                    chat.engine.transmit_update(
2768                        transmission.transmission_id,
2769                        DeliveryState::Failed,
2770                        now_ms,
2771                    );
2772                    continue;
2773                };
2774                // Carry the full source address: a member who misses a
2775                // fragment can only ask us to resend it if our frames name
2776                // the key to address that request to.
2777                let mut options = SendOptions::default().with_full_source();
2778                // An emergency message that only channel members can read is
2779                // not an emergency message. The spec forbids encrypting chat
2780                // on `EMERGENCY` so anyone in range can act on it, whether or
2781                // not they hold the key; the full source key it already
2782                // carries is what keeps it attributable without it.
2783                if channel == crate::emergency_channel_tag() {
2784                    options = options.unencrypted();
2785                }
2786                bound.send_all(&payload, &options).await
2787            }
2788            Destination::ChannelPeer { channel, peer } => {
2789                let Some(bound) = bound_channel(node, channels, &channel) else {
2790                    chat.engine.transmit_update(
2791                        transmission.transmission_id,
2792                        DeliveryState::Failed,
2793                        now_ms,
2794                    );
2795                    continue;
2796                };
2797                // The MAC will only address a registered peer, and a channel
2798                // member is not one — nothing about being in a channel
2799                // together registers anybody. Register on the way out rather
2800                // than on sight: only the members we actually have to ask
2801                // something of spend a peer slot, and this is the only place
2802                // we ever ask.
2803                if node.peer(peer).await.is_err() {
2804                    chat.engine.transmit_update(
2805                        transmission.transmission_id,
2806                        DeliveryState::Failed,
2807                        now_ms,
2808                    );
2809                    continue;
2810                }
2811                // A repair request; the engine owns retrying it, so no ACK is
2812                // asked for here.
2813                let mut options = SendOptions::default().with_full_source();
2814                // Repairs carry the same message the multicast did, so they
2815                // are held to the same rule — and have to be, since the
2816                // receiving side refuses encrypted emergency text whatever
2817                // family it arrives in. It stays blind unicast even so: what
2818                // an unencrypted blind unicast still carries over a plain one
2819                // is the channel it names, which is what a repeater decides
2820                // to forward on.
2821                if channel == crate::emergency_channel_tag() {
2822                    options = options.unencrypted();
2823                }
2824                let r = bound.send(&peer, &payload, &options).await;
2825                r
2826            }
2827        };
2828        let ticket = match sent {
2829            Ok(ticket) => ticket,
2830            Err(_) => {
2831                // With a registered peer and an engine-bounded payload, the
2832                // expected failure here is temporary MAC queue / pending-ACK
2833                // capacity. Preserve ordering and retry after tickets advance.
2834                pending.push_front(transmission);
2835                break;
2836            }
2837        };
2838        in_flight.push(InFlightChatTransmission {
2839            transmission_id: transmission.transmission_id,
2840            gate_peer,
2841            ticket,
2842            sent_reported: false,
2843            non_ack: gate_peer.is_none(),
2844            queued_at_ms: now_ms,
2845            stall_reported: false,
2846        });
2847        queued += 1;
2848        if in_flight.len() >= MOBILE_CHAT_TRANSMIT_WINDOW {
2849            break;
2850        }
2851    }
2852    queued
2853}
2854
2855/// Solicit one channel member's identity over the channel they were heard on.
2856///
2857/// The request is a multicast every member receives but only the filtered hint
2858/// answers. Routing follows the evidence that member's own frames left: their
2859/// trace route if one was observed, otherwise a flood budget no larger than
2860/// the distance they were last heard from.
2861async fn request_identity_over_channel<M: MacBackend>(
2862    node: &LocalNode<M>,
2863    channels: &Rc<RefCell<ChannelRegistry>>,
2864    channel: ChannelTag,
2865    hint: NodeHint,
2866    nonce: u32,
2867    route: Option<MemberRoute>,
2868) -> Result<(), MobileMeshError> {
2869    let Some(bound) = bound_channel(node, channels, &channel) else {
2870        return Err(MobileMeshError::UnknownConversation);
2871    };
2872    let options_block = umsh_node::mac_command::IdentityRequestBuilder::new()
2873        .nonce(nonce)
2874        .and_then(|builder| builder.filter_hint(&hint))
2875        .map_err(|_| MobileMeshError::SendFailed)?
2876        .build();
2877    let cmd = umsh_node::MacCommand::IdentityRequest {
2878        options: &options_block,
2879    };
2880    let mut frame = [0u8; 128];
2881    frame[0] = PayloadType::MacCommand as u8;
2882    let length = umsh_node::mac_command::encode(&cmd, &mut frame[1..])
2883        .map_err(|_| MobileMeshError::SendFailed)?
2884        + 1;
2885    // Full source so the member can answer with a targeted unicast rather
2886    // than another multicast.
2887    let mut options = SendOptions::default().with_full_source();
2888    match route.as_ref() {
2889        Some(route) if !route.route_hints.is_empty() => {
2890            let hops = route
2891                .route_hints
2892                .iter()
2893                .filter_map(|hint| <[u8; 2]>::try_from(hint.as_slice()).ok())
2894                .map(umsh_core::RouterHint)
2895                .collect::<Vec<_>>();
2896            // An over-long observed route is not a reason to fail the
2897            // request; fall back to flooding at the distance it implies.
2898            options = match options.try_with_source_route(&hops) {
2899                Ok(options) => options,
2900                Err(_) => SendOptions::default()
2901                    .with_full_source()
2902                    .with_flood_hops(route.hop_count.unwrap_or(5).max(1)),
2903            };
2904        }
2905        Some(MemberRoute {
2906            hop_count: Some(hops),
2907            ..
2908        }) => {
2909            options = options.with_flood_hops((*hops).max(1));
2910        }
2911        _ => {}
2912    }
2913    bound
2914        .send_all(&frame[..length], &options)
2915        .await
2916        .map(|_| ())
2917        .map_err(|_| MobileMeshError::SendFailed)
2918}
2919
2920/// Bind the channel a transmission names, so it can be sent over.
2921fn bound_channel<M: MacBackend>(
2922    node: &LocalNode<M>,
2923    channels: &Rc<RefCell<ChannelRegistry>>,
2924    channel: &ChannelTag,
2925) -> Option<umsh_node::BoundChannel<M>> {
2926    let key = channels.borrow().key(channel)?;
2927    // Looked up per send rather than cached: leaving and rejoining a channel
2928    // invalidates the binding, and the registry is the one place that knows.
2929    node.bound_channel(&umsh_node::Channel::private(key, ""))
2930}
2931
2932fn service_chat_tickets(
2933    chat: &mut MobileChatState,
2934    in_flight: &mut Vec<InFlightChatTransmission>,
2935    pipeline_ready: &mut BTreeSet<[u8; 32]>,
2936    events: &NotifyingSender<MobileChatWorkerEvent>,
2937    pending_depth: usize,
2938    now_ms: u64,
2939) {
2940    let occupied = in_flight.len();
2941    let mut index = 0;
2942    while index < in_flight.len() {
2943        let entry = &mut in_flight[index];
2944        // Checked before the state transitions below, so an entry that is
2945        // about to retire this pass is not accused on its way out.
2946        if !entry.stall_reported
2947            && !entry.ticket.was_transmitted()
2948            && now_ms.saturating_sub(entry.queued_at_ms) >= CHAT_TRANSMISSION_STALL_MS
2949        {
2950            entry.stall_reported = true;
2951            let waited = now_ms.saturating_sub(entry.queued_at_ms) / 1000;
2952            let transmission_id = entry.transmission_id;
2953            let _ = events.send(MobileChatWorkerEvent::Diagnostic(format!(
2954                "transmission {transmission_id} has not left the radio after {waited}s \
2955                 ({occupied}/{MOBILE_CHAT_TRANSMIT_WINDOW} window slots used, \
2956                 {pending_depth} more waiting)"
2957            )));
2958        }
2959        let entry = &mut in_flight[index];
2960        if entry.ticket.was_transmitted() && !entry.sent_reported {
2961            chat.engine
2962                .transmit_update(entry.transmission_id, DeliveryState::Sent, now_ms);
2963            entry.sent_reported = true;
2964        }
2965        if entry.non_ack && entry.sent_reported {
2966            // Nothing further can happen to this one: no acknowledgement is
2967            // coming, so transmission is where it ends. Retiring it here is
2968            // what keeps channel sends from accumulating in flight forever.
2969            in_flight.swap_remove(index);
2970        } else if entry.ticket.was_acked() {
2971            if let Some(peer) = entry.gate_peer {
2972                pipeline_ready.insert(peer.0);
2973            }
2974            chat.engine
2975                .transmit_update(entry.transmission_id, DeliveryState::Acked, now_ms);
2976            in_flight.swap_remove(index);
2977        } else if entry.ticket.has_failed() {
2978            chat.engine
2979                .transmit_update(entry.transmission_id, DeliveryState::Failed, now_ms);
2980            in_flight.swap_remove(index);
2981        } else {
2982            index += 1;
2983        }
2984    }
2985}
2986
2987fn publish_chat_drain(
2988    drain: crate::mobile_chat::ChatDrain,
2989    events: &NotifyingSender<MobileChatWorkerEvent>,
2990) {
2991    for mutation in drain.mutations {
2992        let _ = events.send(MobileChatWorkerEvent::Mutation(mutation));
2993    }
2994    for delivery in drain.deliveries {
2995        let _ = events.send(MobileChatWorkerEvent::Delivery(delivery));
2996    }
2997    for lookup in drain.lookups {
2998        let _ = events.send(MobileChatWorkerEvent::ArchiveLookup(lookup));
2999    }
3000    for resolution in drain.resolutions {
3001        let _ = events.send(MobileChatWorkerEvent::SenderResolution(resolution));
3002    }
3003    for diagnostic in drain.diagnostics {
3004        let _ = events.send(MobileChatWorkerEvent::Diagnostic(diagnostic));
3005    }
3006}
3007
3008/// Attach a frame's radio metadata to the records it produced.
3009///
3010/// The engine is transport-agnostic, so this is the only place the two are
3011/// together. Only records describing received content carry it — an outbound
3012/// echo or a placeholder has no frame behind it.
3013fn attach_rx_metadata(mutations: &mut [MobileChatMutationRecord], rx: &MobileChatRxMetadataRecord) {
3014    for mutation in mutations {
3015        let describes_receipt = match mutation.kind {
3016            MobileChatMutationKind::Insert => {
3017                mutation.direction == Some(MobileChatDirection::Inbound)
3018                    && mutation.presence == MobileChatPresence::Present
3019            }
3020            MobileChatMutationKind::UpdateBody => true,
3021            MobileChatMutationKind::Edit | MobileChatMutationKind::Delete => false,
3022        };
3023        if describes_receipt {
3024            mutation.rx = Some(rx.clone());
3025        }
3026    }
3027}
3028
3029/// Remember how a channel member was last reached, so a later request to them
3030/// can be routed by evidence instead of by a default flood budget.
3031fn remember_member_route(
3032    routes: &mut BTreeMap<(ChannelTag, [u8; 3]), MemberRoute>,
3033    channel: ChannelTag,
3034    hint: NodeHint,
3035    rx: &MobileChatRxMetadataRecord,
3036) {
3037    routes.insert(
3038        (channel, hint.0),
3039        MemberRoute {
3040            hop_count: rx.hop_count,
3041            route_hints: rx.route_hints.clone(),
3042        },
3043    );
3044}
3045
3046/// What the last frame from a channel member showed about reaching them.
3047#[derive(Clone)]
3048struct MemberRoute {
3049    hop_count: Option<u8>,
3050    route_hints: Vec<Vec<u8>>,
3051}
3052
3053fn decode_peer(address: &str) -> Result<PublicKey, MobileError> {
3054    let bytes = umsh_core::base58::decode(address.as_bytes())?;
3055    Ok(PublicKey(bytes))
3056}
3057
3058fn decode_channel_keys(keys: Vec<Vec<u8>>) -> Result<Vec<ChannelKey>, MobileMeshError> {
3059    keys.into_iter()
3060        .map(|key| {
3061            <[u8; 32]>::try_from(key.as_slice())
3062                .map(ChannelKey)
3063                .map_err(|_| MobileMeshError::InvalidChannelKey)
3064        })
3065        .collect()
3066}
3067
3068/// The canonical fixed-width Base58 rendering of a peer key, matching what
3069/// `decode_peer` accepts and what the platform stores as an address.
3070fn encode_peer_address(peer: &PublicKey) -> String {
3071    umsh_core::base58::encode(&peer.0)
3072        .into_iter()
3073        .map(char::from)
3074        .collect()
3075}
3076
3077fn emit_ping_failure(events: &NotifyingSender<MobileMeshPingEventRecord>, operation_id: u64) {
3078    let _ = events.send(MobileMeshPingEventRecord {
3079        operation_id,
3080        outcome: MobileMeshPingOutcome::Failed,
3081        round_trip_milliseconds: None,
3082        hop_count: None,
3083        route_hints: Vec::new(),
3084        rssi_dbm: None,
3085        snr_centibels: None,
3086        lqi: None,
3087    });
3088}
3089
3090#[cfg(test)]
3091mod tests {
3092    use super::*;
3093    use crate::MobileChatDeliveryState;
3094    use std::time::Instant;
3095    use umsh_crypto::NodeIdentity;
3096
3097    fn identity(seed: u8) -> Arc<MobileIdentity> {
3098        let identity = SoftwareIdentity::from_secret_bytes(&[seed; 32]);
3099        let public_identity = crate::public_identity_record(identity.public_key());
3100        Arc::new(MobileIdentity {
3101            identity: Mutex::new(Some(identity)),
3102            public_identity,
3103        })
3104    }
3105
3106    fn address(identity: &MobileIdentity) -> String {
3107        identity.public_identity.canonical_address.clone()
3108    }
3109
3110    /// The 3-byte hint a node's multicast frames claim, which is the leading
3111    /// bytes of its public key.
3112    fn hint_of(identity: &MobileIdentity) -> Vec<u8> {
3113        decode_peer(&address(identity)).unwrap().0[..3].to_vec()
3114    }
3115
3116    async fn channel_session(name: &str) -> Arc<MobileMeshSession> {
3117        let directory = tempfile::tempdir().unwrap();
3118        let store =
3119            MobileCounterStore::new(directory.path().join(name).display().to_string()).unwrap();
3120        // The temp directory must outlive the session's counter store.
3121        std::mem::forget(directory);
3122        MobileMeshSession::new(identity(31), store).await.unwrap()
3123    }
3124
3125    #[tokio::test]
3126    async fn channel_registration_is_idempotent_and_reversible() {
3127        let session = channel_session("channels").await;
3128        let key = vec![0x5au8; 32];
3129
3130        session.register_channels(vec![key.clone()]).await.unwrap();
3131        // Re-registering an already-joined channel restates the current
3132        // state, which is what a session-start replay does.
3133        session.register_channels(vec![key.clone()]).await.unwrap();
3134        session.remove_channels(vec![key.clone()]).await.unwrap();
3135        // Leaving a channel that is not joined is likewise not an error.
3136        session.remove_channels(vec![key.clone()]).await.unwrap();
3137        // And the key can come back afterwards.
3138        session.register_channels(vec![key]).await.unwrap();
3139    }
3140
3141    #[tokio::test]
3142    async fn channel_keys_must_be_full_length() {
3143        let session = channel_session("shortkey").await;
3144        assert_eq!(
3145            session.register_channels(vec![vec![0x01; 31]]).await,
3146            Err(MobileMeshError::InvalidChannelKey)
3147        );
3148        assert_eq!(
3149            session.remove_channels(vec![Vec::new()]).await,
3150            Err(MobileMeshError::InvalidChannelKey)
3151        );
3152    }
3153
3154    #[tokio::test]
3155    async fn the_phone_mac_holds_more_channels_than_the_embedded_default() {
3156        let session = channel_session("capacity").await;
3157        // Distinct keys, one per slot the phone advertises.
3158        let keys: Vec<Vec<u8>> = (0..MOBILE_MAC_CHANNELS)
3159            .map(|index| {
3160                let mut key = vec![0u8; 32];
3161                key[0] = index as u8;
3162                key[1] = 0xA5;
3163                key
3164            })
3165            .collect();
3166        assert!(keys.len() > umsh_mac::DEFAULT_CHANNELS);
3167        session.register_channels(keys).await.unwrap();
3168
3169        let overflow = vec![vec![0xFFu8; 32]];
3170        assert_eq!(
3171            session.register_channels(overflow).await,
3172            Err(MobileMeshError::ChannelCapacity)
3173        );
3174    }
3175
3176    #[tokio::test]
3177    async fn two_rust_sessions_complete_an_authenticated_ping() {
3178        let directory = tempfile::tempdir().unwrap();
3179        let alice_identity = identity(7);
3180        let bob_identity = identity(9);
3181        let alice_root = directory.path().join("alice");
3182        let bob_root = directory.path().join("bob");
3183        let alice_store = MobileCounterStore::new(alice_root.display().to_string()).unwrap();
3184        let bob_store = MobileCounterStore::new(bob_root.display().to_string()).unwrap();
3185        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
3186            .await
3187            .unwrap();
3188        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
3189            .await
3190            .unwrap();
3191        // Constructing or repeatedly rebooting a session is read-only. The
3192        // first reservation write must be caused by an actual authenticated
3193        // send, never by startup.
3194        assert!(!alice_root.exists());
3195        assert!(!bob_root.exists());
3196
3197        // Each endpoint knows the other peer, as it would from its durable peer
3198        // registry in the application. Starting both pings registers both keys
3199        // through the same public Rust API without test-only MAC access.
3200        let operation = alice.ping(address(&bob_identity), 2_000).unwrap();
3201        let _ = bob.ping(address(&alice_identity), 2_000).unwrap();
3202        let deadline = Instant::now() + Duration::from_secs(10);
3203        loop {
3204            let alice_update = alice.poll_update();
3205            for frame in alice_update.outbound_frames {
3206                assert!(
3207                    alice_root.exists(),
3208                    "Alice released a frame before persisting its reservation"
3209                );
3210                alice.complete_outbound_frame(frame.id, true).unwrap();
3211                bob.receive(MobileMeshRxRecord {
3212                    data: frame.data,
3213                    rssi_dbm: Some(-40),
3214                    lqi: None,
3215                    snr_cb: Some(100),
3216                })
3217                .unwrap();
3218            }
3219            if let Some(event) = alice_update.ping_events.into_iter().next() {
3220                assert_eq!(event.operation_id, operation);
3221                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
3222                assert!(event.round_trip_milliseconds.is_some());
3223                assert_eq!(event.hop_count, Some(1));
3224                assert!(event.route_hints.is_empty());
3225                assert_eq!(event.rssi_dbm, Some(-42));
3226                assert_eq!(event.snr_centibels, Some(90));
3227                assert_eq!(event.lqi, None);
3228                break;
3229            }
3230
3231            let bob_update = bob.poll_update();
3232            for frame in bob_update.outbound_frames {
3233                assert!(
3234                    bob_root.exists(),
3235                    "Bob released a frame before persisting its reservation"
3236                );
3237                bob.complete_outbound_frame(frame.id, true).unwrap();
3238                alice
3239                    .receive(MobileMeshRxRecord {
3240                        data: frame.data,
3241                        rssi_dbm: Some(-42),
3242                        lqi: None,
3243                        snr_cb: Some(90),
3244                    })
3245                    .unwrap();
3246            }
3247            assert!(Instant::now() < deadline, "ping did not complete");
3248            std::thread::sleep(Duration::from_millis(5));
3249        }
3250    }
3251
3252    /// Drive one authenticated ping between the two sessions to completion,
3253    /// shuttling frames both ways.
3254    async fn complete_ping(alice: &MobileMeshSession, bob: &MobileMeshSession, target: String) {
3255        let operation = alice.ping(target, 2_000).unwrap();
3256        let deadline = Instant::now() + Duration::from_secs(10);
3257        loop {
3258            let alice_update = alice.poll_update();
3259            for frame in alice_update.outbound_frames {
3260                alice.complete_outbound_frame(frame.id, true).unwrap();
3261                bob.receive(MobileMeshRxRecord {
3262                    data: frame.data,
3263                    rssi_dbm: Some(-40),
3264                    lqi: None,
3265                    snr_cb: Some(100),
3266                })
3267                .unwrap();
3268            }
3269            if let Some(event) = alice_update.ping_events.into_iter().next() {
3270                assert_eq!(event.operation_id, operation);
3271                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
3272                break;
3273            }
3274            let bob_update = bob.poll_update();
3275            for frame in bob_update.outbound_frames {
3276                bob.complete_outbound_frame(frame.id, true).unwrap();
3277                alice
3278                    .receive(MobileMeshRxRecord {
3279                        data: frame.data,
3280                        rssi_dbm: Some(-42),
3281                        lqi: None,
3282                        snr_cb: Some(90),
3283                    })
3284                    .unwrap();
3285            }
3286            assert!(Instant::now() < deadline, "ping did not complete");
3287            std::thread::sleep(Duration::from_millis(5));
3288        }
3289    }
3290
3291    #[tokio::test]
3292    async fn removed_peer_re_registers_cleanly_and_traffic_still_flows() {
3293        let directory = tempfile::tempdir().unwrap();
3294        let alice_identity = identity(21);
3295        let bob_identity = identity(23);
3296        let alice_store =
3297            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
3298        let bob_store =
3299            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
3300        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
3301            .await
3302            .unwrap();
3303        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
3304            .await
3305            .unwrap();
3306
3307        alice
3308            .register_peers(vec![address(&bob_identity)])
3309            .await
3310            .unwrap();
3311        bob.register_peers(vec![address(&alice_identity)])
3312            .await
3313            .unwrap();
3314        complete_ping(&alice, &bob, address(&bob_identity)).await;
3315
3316        // Removal is idempotent — an unknown peer and a double removal are
3317        // both fine — and must not disturb the session.
3318        alice
3319            .remove_peers(vec![address(&bob_identity)])
3320            .await
3321            .unwrap();
3322        alice
3323            .remove_peers(vec![address(&bob_identity)])
3324            .await
3325            .unwrap();
3326        alice
3327            .remove_peers(vec![address(&alice_identity)])
3328            .await
3329            .unwrap();
3330
3331        // Re-registering after removal starts from a clean slot; Bob's
3332        // replay state still accepts Alice because her TX counter is
3333        // identity-scoped and survived the peer-table churn.
3334        alice
3335            .register_peers(vec![address(&bob_identity)])
3336            .await
3337            .unwrap();
3338        complete_ping(&alice, &bob, address(&bob_identity)).await;
3339    }
3340
3341    #[tokio::test]
3342    async fn discover_identities_emits_one_acceptable_zero_hop_broadcast() {
3343        let directory = tempfile::tempdir().unwrap();
3344        let alice_identity = identity(31);
3345        let bob_identity = identity(33);
3346        let alice_store =
3347            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
3348        let bob_store =
3349            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
3350        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
3351            .await
3352            .unwrap();
3353        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
3354            .await
3355            .unwrap();
3356
3357        alice.discover_identities(None, Some(0x02)).await.unwrap();
3358
3359        let deadline = Instant::now() + Duration::from_secs(10);
3360        let frames = loop {
3361            let update = alice.poll_update();
3362            if !update.outbound_frames.is_empty() {
3363                break update.outbound_frames;
3364            }
3365            assert!(Instant::now() < deadline, "solicitation never went out");
3366            std::thread::sleep(Duration::from_millis(5));
3367        };
3368        // One broadcast, no retries, no companions.
3369        assert_eq!(frames.len(), 1);
3370        let frame = frames.into_iter().next().unwrap();
3371        alice.complete_outbound_frame(frame.id, true).unwrap();
3372        let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
3373        assert_eq!(header.packet_type(), umsh_core::PacketType::Broadcast);
3374        // Zero-hop: no flood budget for repeaters to spend.
3375        assert!(header.flood_hops.is_none());
3376        // Full source, so a stranger can unicast its identity back.
3377        assert!(header.fcf.full_source());
3378
3379        // A bystander session consumes the solicitation without error.
3380        // (Whether it answers is its responder's business — the full
3381        // reply loop is covered separately below.)
3382        bob.receive(MobileMeshRxRecord {
3383            data: frame.data,
3384            rssi_dbm: Some(-40),
3385            lqi: None,
3386            snr_cb: Some(100),
3387        })
3388        .unwrap();
3389
3390        // An unrestricted ask still satisfies the rule that a broadcast
3391        // request carries at least one filter: it gets a zero-bit
3392        // capability filter, which every node matches.
3393        alice.discover_identities(None, None).await.unwrap();
3394        let deadline = Instant::now() + Duration::from_secs(10);
3395        let frames = loop {
3396            let update = alice.poll_update();
3397            if !update.outbound_frames.is_empty() {
3398                break update.outbound_frames;
3399            }
3400            assert!(Instant::now() < deadline, "solicitation never went out");
3401            std::thread::sleep(Duration::from_millis(5));
3402        };
3403        assert_eq!(frames.len(), 1);
3404        let frame = frames.into_iter().next().unwrap();
3405        alice.complete_outbound_frame(frame.id, true).unwrap();
3406        let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
3407        let body = &frame.data[header.body_range.clone()];
3408        assert_eq!(body[0], umsh_core::PayloadType::MacCommand as u8);
3409        let umsh_node::MacCommand::IdentityRequest { options } =
3410            umsh_node::mac_command::parse(&body[1..]).unwrap()
3411        else {
3412            panic!("expected an identity request");
3413        };
3414        let has_vacuous_caps_filter = umsh_core::options::OptionDecoder::new(options)
3415            .filter_map(Result::ok)
3416            .any(|(number, value)| {
3417                number == umsh_node::mac_command::identity_filter::FILTER_NODE_CAPS && value == [0]
3418            });
3419        assert!(has_vacuous_caps_filter);
3420    }
3421
3422    /// The whole discover loop between two strangers: Alice's zero-hop
3423    /// broadcast ask reaches Bob, Bob's default-on responder answers with
3424    /// a jittered authenticated unicast carrying his full source key, and
3425    /// Alice — who has never registered Bob — auto-registers him
3426    /// transiently, verifies the reply, and surfaces it as an
3427    /// advertisement event. This is the exact path the Discover sheet
3428    /// rides on hardware.
3429    #[tokio::test]
3430    async fn discover_solicitation_earns_a_stranger_reply_end_to_end() {
3431        let directory = tempfile::tempdir().unwrap();
3432        let alice_identity = identity(21);
3433        let bob_identity = identity(23);
3434        let alice_store =
3435            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
3436        let bob_store =
3437            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
3438        // Bob's reply is held for a random slice of the 30-second identity
3439        // response window. Virtual time collapses that wait whenever his
3440        // worker is otherwise idle, so the deadline below bounds the shuttle
3441        // loop rather than the protocol delay.
3442        let alice = MobileMeshSession::new_with_virtual_time(alice_identity.clone(), alice_store)
3443            .await
3444            .unwrap();
3445        let bob = MobileMeshSession::new_with_virtual_time(bob_identity.clone(), bob_store)
3446            .await
3447            .unwrap();
3448        // Bob answers under a display name; Alice should hear it back.
3449        bob.set_discoverable(true, Some("Bob's phone".into()))
3450            .await
3451            .unwrap();
3452        // And under a shared location: the responder serves the same
3453        // description an advertisement carries.
3454        bob.set_advertised_location(Some(MobileMeshSharedLocationRecord {
3455            latitude_degrees: 48.1173,
3456            longitude_degrees: 11.5167,
3457            precision_bytes: 5,
3458        }))
3459        .await
3460        .unwrap();
3461
3462        alice.discover_identities(None, None).await.unwrap();
3463
3464        // Shuttle frames both ways until Bob's identity lands at Alice.
3465        let bob_address = address(&bob_identity);
3466        let deadline = Instant::now() + Duration::from_secs(15);
3467        let event = 'outer: loop {
3468            let alice_update = alice.poll_update();
3469            for frame in alice_update.outbound_frames {
3470                alice.complete_outbound_frame(frame.id, true).unwrap();
3471                bob.receive(MobileMeshRxRecord {
3472                    data: frame.data,
3473                    rssi_dbm: Some(-40),
3474                    lqi: None,
3475                    snr_cb: Some(100),
3476                })
3477                .unwrap();
3478            }
3479            for event in alice_update.advertisement_events {
3480                if event.peer_address == bob_address {
3481                    break 'outer event;
3482                }
3483            }
3484            let bob_update = bob.poll_update();
3485            for frame in bob_update.outbound_frames {
3486                bob.complete_outbound_frame(frame.id, true).unwrap();
3487                alice
3488                    .receive(MobileMeshRxRecord {
3489                        data: frame.data,
3490                        rssi_dbm: Some(-42),
3491                        lqi: None,
3492                        snr_cb: Some(90),
3493                    })
3494                    .unwrap();
3495            }
3496            assert!(Instant::now() < deadline, "no identity reply reached Alice");
3497            std::thread::sleep(Duration::from_millis(5));
3498        };
3499        // The reply is a MAC-authenticated unicast, not a broadcast the
3500        // platform still has to signature-check.
3501        assert!(event.source_authenticated);
3502        let payload = umsh_node::NodeIdentityPayload::from_bytes(&event.payload).unwrap();
3503        assert_eq!(payload.name.as_deref(), Some("Bob's phone"));
3504        let cell = payload
3505            .location
3506            .expect("the reply serves the shared location");
3507        assert_eq!(cell.precision(), 5);
3508        let (lat, lon) = cell.center();
3509        assert!((f64::from(lat) - 48.1173).abs() < 0.01);
3510        assert!((f64::from(lon) - 11.5167).abs() < 0.01);
3511
3512        // Opting out is honored: a fresh ask earns silence from Bob.
3513        bob.set_discoverable(false, None).await.unwrap();
3514        alice.discover_identities(None, None).await.unwrap();
3515        let quiet_until = Instant::now() + Duration::from_secs(6);
3516        while Instant::now() < quiet_until {
3517            let alice_update = alice.poll_update();
3518            for frame in alice_update.outbound_frames {
3519                alice.complete_outbound_frame(frame.id, true).unwrap();
3520                bob.receive(MobileMeshRxRecord {
3521                    data: frame.data,
3522                    rssi_dbm: Some(-40),
3523                    lqi: None,
3524                    snr_cb: Some(100),
3525                })
3526                .unwrap();
3527            }
3528            let bob_update = bob.poll_update();
3529            assert!(
3530                bob_update.outbound_frames.is_empty(),
3531                "Bob answered while not discoverable"
3532            );
3533            std::thread::sleep(Duration::from_millis(20));
3534        }
3535    }
3536
3537    #[tokio::test]
3538    async fn peer_route_is_visible_and_resettable() {
3539        let directory = tempfile::tempdir().unwrap();
3540        let alice_identity = identity(11);
3541        let bob_identity = identity(13);
3542        let alice_store =
3543            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
3544        let bob_store =
3545            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
3546        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
3547            .await
3548            .unwrap();
3549        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
3550            .await
3551            .unwrap();
3552
3553        // A peer nobody has heard from has no route, and inspecting it must
3554        // not register the peer or invent one.
3555        assert_eq!(
3556            alice.peer_route(address(&bob_identity)).await.unwrap(),
3557            MobileMeshRouteRecord::unknown()
3558        );
3559        assert!(
3560            !alice
3561                .clear_peer_route(address(&bob_identity))
3562                .await
3563                .unwrap()
3564        );
3565
3566        let operation = alice.ping(address(&bob_identity), 2_000).unwrap();
3567        let _ = bob.ping(address(&alice_identity), 2_000).unwrap();
3568        let deadline = Instant::now() + Duration::from_secs(10);
3569        loop {
3570            let alice_update = alice.poll_update();
3571            for frame in alice_update.outbound_frames {
3572                alice.complete_outbound_frame(frame.id, true).unwrap();
3573                bob.receive(MobileMeshRxRecord {
3574                    data: frame.data,
3575                    rssi_dbm: Some(-40),
3576                    lqi: None,
3577                    snr_cb: Some(100),
3578                })
3579                .unwrap();
3580            }
3581            if let Some(event) = alice_update.ping_events.into_iter().next() {
3582                assert_eq!(event.operation_id, operation);
3583                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
3584                break;
3585            }
3586
3587            let bob_update = bob.poll_update();
3588            for frame in bob_update.outbound_frames {
3589                bob.complete_outbound_frame(frame.id, true).unwrap();
3590                alice
3591                    .receive(MobileMeshRxRecord {
3592                        data: frame.data,
3593                        rssi_dbm: Some(-42),
3594                        lqi: None,
3595                        snr_cb: Some(90),
3596                    })
3597                    .unwrap();
3598            }
3599            assert!(Instant::now() < deadline, "ping did not complete");
3600            std::thread::sleep(Duration::from_millis(5));
3601        }
3602
3603        // The pong carried a trace route that accumulated no hints, because
3604        // there is no repeater between the two. That is a direct peer — not a
3605        // source route naming no routers, which would put an empty (and
3606        // meaningless) SourceRoute option on every packet alice sends back.
3607        let route = alice.peer_route(address(&bob_identity)).await.unwrap();
3608        assert_eq!(route.kind, MobileMeshRouteKind::Direct);
3609        assert!(route.hints.is_empty());
3610        assert_eq!(route.flood_hops, None);
3611
3612        // Resetting reports that a route was held, and leaves the peer with
3613        // nothing cached. A second reset has nothing left to discard.
3614        assert!(
3615            alice
3616                .clear_peer_route(address(&bob_identity))
3617                .await
3618                .unwrap()
3619        );
3620        assert_eq!(
3621            alice.peer_route(address(&bob_identity)).await.unwrap(),
3622            MobileMeshRouteRecord::unknown()
3623        );
3624        assert!(
3625            !alice
3626                .clear_peer_route(address(&bob_identity))
3627                .await
3628                .unwrap()
3629        );
3630
3631        // Clearing a route must not disturb the peer's crypto state: the next
3632        // ping still completes, and teaches the route again.
3633        let operation = alice.ping(address(&bob_identity), 2_000).unwrap();
3634        let deadline = Instant::now() + Duration::from_secs(10);
3635        loop {
3636            let alice_update = alice.poll_update();
3637            for frame in alice_update.outbound_frames {
3638                alice.complete_outbound_frame(frame.id, true).unwrap();
3639                bob.receive(MobileMeshRxRecord {
3640                    data: frame.data,
3641                    rssi_dbm: Some(-40),
3642                    lqi: None,
3643                    snr_cb: Some(100),
3644                })
3645                .unwrap();
3646            }
3647            if let Some(event) = alice_update.ping_events.into_iter().next() {
3648                assert_eq!(event.operation_id, operation);
3649                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
3650                break;
3651            }
3652
3653            let bob_update = bob.poll_update();
3654            for frame in bob_update.outbound_frames {
3655                bob.complete_outbound_frame(frame.id, true).unwrap();
3656                alice
3657                    .receive(MobileMeshRxRecord {
3658                        data: frame.data,
3659                        rssi_dbm: Some(-42),
3660                        lqi: None,
3661                        snr_cb: Some(90),
3662                    })
3663                    .unwrap();
3664            }
3665            assert!(
3666                Instant::now() < deadline,
3667                "ping after reset did not complete"
3668            );
3669            std::thread::sleep(Duration::from_millis(5));
3670        }
3671        assert_eq!(
3672            alice.peer_route(address(&bob_identity)).await.unwrap().kind,
3673            MobileMeshRouteKind::Direct
3674        );
3675    }
3676
3677    #[tokio::test]
3678    async fn broadcast_advertisement_reaches_peer_with_valid_signature() {
3679        let directory = tempfile::tempdir().unwrap();
3680        let alice_identity = identity(21);
3681        let bob_identity = identity(23);
3682        let alice_store =
3683            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
3684        let bob_store =
3685            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
3686        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
3687            .await
3688            .unwrap();
3689        let bob = MobileMeshSession::new(bob_identity, bob_store)
3690            .await
3691            .unwrap();
3692
3693        // The signed bundle used for QR/URI sharing verifies out of band.
3694        let bundle = alice
3695            .sign_identity_bundle(Some("Alice's Phone".to_owned()), Some(1_760_000_000))
3696            .await
3697            .unwrap();
3698        let record = crate::decode_node_identity(address(&alice_identity), bundle.clone()).unwrap();
3699        assert_eq!(record.signature, crate::IdentitySignatureState::Valid);
3700        assert_eq!(record.name.as_deref(), Some("Alice's Phone"));
3701        assert_eq!(record.role_label, "Chat");
3702        let uri = crate::node_uri_with_identity(address(&alice_identity), bundle).unwrap();
3703        assert!(
3704            crate::inspect_node_uri(uri)
3705                .unwrap()
3706                .identity_payload
3707                .is_some()
3708        );
3709
3710        alice
3711            .advertise_identity(Some("Alice's Phone".to_owned()), None)
3712            .await
3713            .unwrap();
3714
3715        let deadline = Instant::now() + Duration::from_secs(10);
3716        loop {
3717            for frame in alice.poll_update().outbound_frames {
3718                alice.complete_outbound_frame(frame.id, true).unwrap();
3719                bob.receive(MobileMeshRxRecord {
3720                    data: frame.data,
3721                    rssi_dbm: Some(-50),
3722                    lqi: None,
3723                    snr_cb: None,
3724                })
3725                .unwrap();
3726            }
3727            let bob_update = bob.poll_update();
3728            // Presence is reported for the same frame, independently of what
3729            // it carried: this is the only signal a payload-free beacon
3730            // produces, so it must not be conditional on a payload.
3731            let heard = bob_update.peer_heard_events;
3732            if let Some(event) = bob_update.advertisement_events.into_iter().next() {
3733                assert_eq!(
3734                    heard.iter().find_map(|record| record.peer_address.clone()),
3735                    Some(address(&alice_identity)),
3736                    "the frame that carried the advertisement also reported presence"
3737                );
3738                assert_eq!(event.peer_address, address(&alice_identity));
3739                // A broadcast has no MIC, so the platform is told the sender
3740                // was not authenticated and must fall back to the bundle's
3741                // own signature — which is why one is attached.
3742                assert!(!event.source_authenticated);
3743                let received =
3744                    crate::decode_node_identity(event.peer_address, event.payload).unwrap();
3745                assert_eq!(received.signature, crate::IdentitySignatureState::Valid);
3746                assert_eq!(received.name.as_deref(), Some("Alice's Phone"));
3747                break;
3748            }
3749            assert!(Instant::now() < deadline, "advertisement not received");
3750            std::thread::sleep(Duration::from_millis(5));
3751        }
3752    }
3753
3754    /// The location policy in one pass: a shared cell rides every live
3755    /// advertisement, never the durable QR/URI bundle, and clearing it
3756    /// removes it from the next send rather than lingering.
3757    #[tokio::test]
3758    async fn a_shared_location_rides_adverts_but_never_the_durable_bundle() {
3759        let directory = tempfile::tempdir().unwrap();
3760        let alice_identity = identity(51);
3761        let bob_identity = identity(53);
3762        let alice_store =
3763            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
3764        let bob_store =
3765            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
3766        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
3767            .await
3768            .unwrap();
3769        let bob = MobileMeshSession::new(bob_identity, bob_store)
3770            .await
3771            .unwrap();
3772
3773        alice
3774            .set_advertised_location(Some(MobileMeshSharedLocationRecord {
3775                latitude_degrees: 37.774_929,
3776                longitude_degrees: -122.419_416,
3777                precision_bytes: 5,
3778            }))
3779            .await
3780            .unwrap();
3781
3782        // The durable bundle stays location-free while a location is live:
3783        // it outlives the moment and travels wherever the QR is pasted.
3784        let bundle = alice
3785            .sign_identity_bundle(Some("Alice's Phone".to_owned()), None)
3786            .await
3787            .unwrap();
3788        let record = crate::decode_node_identity(address(&alice_identity), bundle).unwrap();
3789        assert_eq!(record.signature, crate::IdentitySignatureState::Valid);
3790        assert!(
3791            record.latitude.is_none(),
3792            "a QR bundle never places its owner"
3793        );
3794
3795        // One advertisement while sharing, one after clearing.
3796        let mut received = Vec::new();
3797        for share in [true, false] {
3798            if !share {
3799                alice.set_advertised_location(None).await.unwrap();
3800            }
3801            alice.advertise_identity(None, None).await.unwrap();
3802            let deadline = Instant::now() + Duration::from_secs(10);
3803            'advert: loop {
3804                for frame in alice.poll_update().outbound_frames {
3805                    alice.complete_outbound_frame(frame.id, true).unwrap();
3806                    bob.receive(MobileMeshRxRecord {
3807                        data: frame.data,
3808                        rssi_dbm: Some(-50),
3809                        lqi: None,
3810                        snr_cb: None,
3811                    })
3812                    .unwrap();
3813                }
3814                for event in bob.poll_update().advertisement_events {
3815                    received.push(
3816                        crate::decode_node_identity(event.peer_address, event.payload).unwrap(),
3817                    );
3818                    break 'advert;
3819                }
3820                assert!(Instant::now() < deadline, "advertisement not received");
3821                std::thread::sleep(Duration::from_millis(5));
3822            }
3823        }
3824
3825        let shared = &received[0];
3826        assert_eq!(shared.location_precision, Some(5));
3827        assert!((shared.latitude.unwrap() - 37.774_929).abs() < 0.01);
3828        assert!((shared.longitude.unwrap() + 122.419_416).abs() < 0.01);
3829        // Clearing is complete: the next advertisement places nobody.
3830        assert!(received[1].latitude.is_none());
3831    }
3832
3833    /// The refusals: a coordinate that names no place, at either end of
3834    /// the record, never reaches the worker.
3835    #[test]
3836    fn a_location_that_names_no_place_is_refused() {
3837        let valid = MobileMeshSharedLocationRecord {
3838            latitude_degrees: 37.774_929,
3839            longitude_degrees: -122.419_416,
3840            precision_bytes: 5,
3841        };
3842        for broken in [
3843            // Precision zero encodes "unspecified", which the API spells
3844            // `None`; a record saying both is a confusion to reject.
3845            MobileMeshSharedLocationRecord {
3846                precision_bytes: 0,
3847                ..valid
3848            },
3849            MobileMeshSharedLocationRecord {
3850                precision_bytes: MAX_PRECISION + 1,
3851                ..valid
3852            },
3853            MobileMeshSharedLocationRecord {
3854                latitude_degrees: 90.1,
3855                ..valid
3856            },
3857            MobileMeshSharedLocationRecord {
3858                longitude_degrees: -180.1,
3859                ..valid
3860            },
3861            MobileMeshSharedLocationRecord {
3862                latitude_degrees: f64::NAN,
3863                ..valid
3864            },
3865        ] {
3866            assert!(matches!(
3867                disclosed_cell(broken),
3868                Err(MobileMeshError::InvalidLocation)
3869            ));
3870        }
3871        let cell = disclosed_cell(valid).unwrap();
3872        assert_eq!(cell.precision(), 5);
3873    }
3874
3875    /// A beacon carries no payload at all, so what reaches a listener is
3876    /// presence and a trace — never an advertisement.
3877    #[tokio::test]
3878    async fn a_beacon_reports_presence_and_carries_nothing() {
3879        let directory = tempfile::tempdir().unwrap();
3880        let alice_identity = identity(31);
3881        let bob_identity = identity(33);
3882        let alice_store =
3883            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
3884        let bob_store =
3885            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
3886        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
3887            .await
3888            .unwrap();
3889        let bob = MobileMeshSession::new(bob_identity, bob_store)
3890            .await
3891            .unwrap();
3892
3893        alice.send_beacon().await.unwrap();
3894
3895        let deadline = Instant::now() + Duration::from_secs(10);
3896        loop {
3897            for frame in alice.poll_update().outbound_frames {
3898                let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
3899                assert!(header.is_beacon(), "a beacon carries no body");
3900                let options =
3901                    umsh_core::ParsedOptions::extract(&frame.data, header.options_range.clone())
3902                        .unwrap();
3903                assert!(options.trace_route.is_some());
3904                assert!(
3905                    options.trace_signal.is_some(),
3906                    "the pair is what makes the trace worth collecting"
3907                );
3908                alice.complete_outbound_frame(frame.id, true).unwrap();
3909                bob.receive(MobileMeshRxRecord {
3910                    data: frame.data,
3911                    rssi_dbm: Some(-50),
3912                    lqi: None,
3913                    snr_cb: None,
3914                })
3915                .unwrap();
3916            }
3917            let bob_update = bob.poll_update();
3918            assert!(
3919                bob_update.advertisement_events.is_empty(),
3920                "an empty beacon identifies nobody"
3921            );
3922            if let Some(heard) = bob_update.peer_heard_events.into_iter().next() {
3923                // Hint-only, not a full key: a beacon is the cheapest
3924                // thing this phone can say, and the 29 bytes a key costs
3925                // buy nothing a listener who already knows it needs.
3926                assert_eq!(heard.peer_address, None);
3927                assert_eq!(heard.node_hint, Some(hint_of(&alice_identity)));
3928                assert!(!heard.source_authenticated);
3929                break;
3930            }
3931            assert!(Instant::now() < deadline, "beacon not received");
3932            std::thread::sleep(Duration::from_millis(5));
3933        }
3934    }
3935
3936    /// The two advertisement paths differ only in reach: a manual one
3937    /// floods and traces so a stranger can find its way back, a scheduled
3938    /// one restates to whoever can already hear this phone.
3939    #[tokio::test]
3940    async fn a_scheduled_advertisement_stays_with_the_neighbours() {
3941        let directory = tempfile::tempdir().unwrap();
3942        let local_identity = identity(41);
3943        let store =
3944            MobileCounterStore::new(directory.path().join("local").display().to_string()).unwrap();
3945        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
3946
3947        session
3948            .advertise_identity_scheduled(Some("Phone".to_owned()), None)
3949            .await
3950            .unwrap();
3951
3952        let deadline = Instant::now() + Duration::from_secs(10);
3953        loop {
3954            let frames = session.poll_update().outbound_frames;
3955            if let Some(frame) = frames.into_iter().next() {
3956                let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
3957                assert!(
3958                    header.flood_hops.is_none(),
3959                    "a scheduled advertisement is not flooded"
3960                );
3961                let options =
3962                    umsh_core::ParsedOptions::extract(&frame.data, header.options_range.clone())
3963                        .unwrap();
3964                assert!(options.trace_route.is_none());
3965                assert!(
3966                    header.fcf.full_source(),
3967                    "the detached signature is only checkable against the key"
3968                );
3969                session.complete_outbound_frame(frame.id, true).unwrap();
3970                break;
3971            }
3972            assert!(Instant::now() < deadline, "advertisement never queued");
3973            std::thread::sleep(Duration::from_millis(5));
3974        }
3975    }
3976
3977    /// The virtual-time seam: with the worker runtime's clock paused, a
3978    /// 30-second protocol timeout resolves in wall-clock milliseconds. This
3979    /// is the harness for exercising MAC ACK timeouts, repair timers, and
3980    /// retry cadences deterministically without real sleeps.
3981    #[tokio::test]
3982    async fn virtual_time_fast_forwards_protocol_timeouts() {
3983        let directory = tempfile::tempdir().unwrap();
3984        let local_identity = identity(61);
3985        let silent_peer = identity(62);
3986        let store = MobileCounterStore::new(directory.path().join("virtual").display().to_string())
3987            .unwrap();
3988        let session = MobileMeshSession::new_with_virtual_time(local_identity, store)
3989            .await
3990            .unwrap();
3991        let started = Instant::now();
3992        let operation = session.ping(address(&silent_peer), 30_000).unwrap();
3993
3994        let deadline = started + Duration::from_secs(5);
3995        loop {
3996            let update = session.poll_update();
3997            for frame in update.outbound_frames {
3998                session.complete_outbound_frame(frame.id, true).unwrap();
3999            }
4000            if let Some(event) = update.ping_events.into_iter().next() {
4001                assert_eq!(event.operation_id, operation);
4002                assert_eq!(event.outcome, MobileMeshPingOutcome::TimedOut);
4003                break;
4004            }
4005            assert!(
4006                Instant::now() < deadline,
4007                "virtual-time ping timeout never fired"
4008            );
4009            std::thread::sleep(Duration::from_millis(2));
4010        }
4011        assert!(
4012            started.elapsed() < Duration::from_secs(5),
4013            "a 30s virtual timeout must not take real-time seconds"
4014        );
4015    }
4016
4017    #[tokio::test]
4018    async fn silent_peer_completes_with_timeout_event() {
4019        let directory = tempfile::tempdir().unwrap();
4020        let local_identity = identity(11);
4021        let silent_peer = identity(13);
4022        let store =
4023            MobileCounterStore::new(directory.path().join("local").display().to_string()).unwrap();
4024        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
4025        let operation = session.ping(address(&silent_peer), 100).unwrap();
4026        let deadline = Instant::now() + Duration::from_secs(2);
4027
4028        loop {
4029            let update = session.poll_update();
4030            for frame in update.outbound_frames {
4031                session.complete_outbound_frame(frame.id, true).unwrap();
4032            }
4033            if let Some(event) = update.ping_events.into_iter().next() {
4034                assert_eq!(event.operation_id, operation);
4035                assert_eq!(event.outcome, MobileMeshPingOutcome::TimedOut);
4036                assert_eq!(event.round_trip_milliseconds, None);
4037                assert_eq!(event.hop_count, None);
4038                assert!(event.route_hints.is_empty());
4039                assert_eq!(event.rssi_dbm, None);
4040                break;
4041            }
4042            assert!(Instant::now() < deadline, "silent ping never timed out");
4043            std::thread::sleep(Duration::from_millis(10));
4044        }
4045    }
4046
4047    struct TestWakeListener {
4048        signal: std_mpsc::Sender<()>,
4049    }
4050
4051    impl MobileMeshWakeListener for TestWakeListener {
4052        fn on_update_pending(&self) {
4053            let _ = self.signal.send(());
4054        }
4055    }
4056
4057    /// The wake listener replaces platform-side polling: it must fire when
4058    /// data becomes pending without any poll_update call, coalesce while
4059    /// pending, and re-arm after each drain.
4060    #[tokio::test]
4061    async fn wake_listener_fires_on_pending_data_and_rearms_after_drain() {
4062        let directory = tempfile::tempdir().unwrap();
4063        let local_identity = identity(63);
4064        let silent_peer = identity(64);
4065        let store =
4066            MobileCounterStore::new(directory.path().join("wake").display().to_string()).unwrap();
4067        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
4068        let (signal, wakes) = std_mpsc::channel();
4069        session.set_wake_listener(Arc::new(TestWakeListener { signal }));
4070
4071        // The ping's outbound frame must announce itself with no polling.
4072        let operation = session.ping(address(&silent_peer), 100).unwrap();
4073        wakes
4074            .recv_timeout(Duration::from_secs(5))
4075            .expect("no wake for the outbound ping frame");
4076
4077        let update = session.poll_update();
4078        assert!(
4079            !update.outbound_frames.is_empty(),
4080            "wake fired but nothing was pending"
4081        );
4082        for frame in update.outbound_frames {
4083            session.complete_outbound_frame(frame.id, true).unwrap();
4084        }
4085
4086        // The drain re-armed the signal: the ping-timeout event a moment
4087        // later must produce a second wake.
4088        wakes
4089            .recv_timeout(Duration::from_secs(5))
4090            .expect("no wake for the ping timeout event");
4091        let deadline = Instant::now() + Duration::from_secs(2);
4092        loop {
4093            let update = session.poll_update();
4094            if let Some(event) = update.ping_events.into_iter().next() {
4095                assert_eq!(event.operation_id, operation);
4096                assert_eq!(event.outcome, MobileMeshPingOutcome::TimedOut);
4097                break;
4098            }
4099            assert!(Instant::now() < deadline, "timeout event never surfaced");
4100            std::thread::sleep(Duration::from_millis(5));
4101        }
4102    }
4103
4104    /// A listener registered after data is already pending is told
4105    /// immediately instead of waiting for the next protocol event.
4106    #[tokio::test]
4107    async fn wake_listener_registered_late_fires_for_already_pending_data() {
4108        let directory = tempfile::tempdir().unwrap();
4109        let local_identity = identity(65);
4110        let silent_peer = identity(66);
4111        let store =
4112            MobileCounterStore::new(directory.path().join("wake-late").display().to_string())
4113                .unwrap();
4114        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
4115
4116        session.ping(address(&silent_peer), 5_000).unwrap();
4117        // Give the worker time to enqueue the outbound frame first; even if
4118        // it loses this race, the enqueue itself fires the listener, so the
4119        // assertion below holds either way.
4120        std::thread::sleep(Duration::from_millis(200));
4121
4122        let (signal, wakes) = std_mpsc::channel();
4123        session.set_wake_listener(Arc::new(TestWakeListener { signal }));
4124        wakes
4125            .recv_timeout(Duration::from_secs(5))
4126            .expect("late-registered listener never fired");
4127        assert!(!session.poll_update().outbound_frames.is_empty());
4128    }
4129
4130    #[tokio::test]
4131    async fn chat_checkpoint_batch_gates_transmission_and_delivers_owned_mutation() {
4132        let directory = tempfile::tempdir().unwrap();
4133        let alice_identity = identity(21);
4134        let bob_identity = identity(22);
4135        let alice_root = directory.path().join("chat-alice");
4136        let alice_store = MobileCounterStore::new(alice_root.display().to_string()).unwrap();
4137        let bob_store =
4138            MobileCounterStore::new(directory.path().join("chat-bob").display().to_string())
4139                .unwrap();
4140        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
4141            .await
4142            .unwrap();
4143        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
4144            .await
4145            .unwrap();
4146        let alice_address = address(&alice_identity);
4147        alice
4148            .register_peers(vec![address(&bob_identity)])
4149            .await
4150            .unwrap();
4151        bob.register_peers(vec![alice_address.clone()])
4152            .await
4153            .unwrap();
4154
4155        let batch = alice
4156            .compose_text(address(&bob_identity), 77, "hello from Rust".to_owned())
4157            .await
4158            .unwrap();
4159        assert_eq!(
4160            batch.checkpoint.conversation_address,
4161            address(&bob_identity)
4162        );
4163        assert!(!batch.archives.is_empty());
4164        assert_eq!(batch.mutations.len(), 1);
4165        assert_eq!(batch.mutations[0].body.as_deref(), Some("hello from Rust"));
4166        assert_eq!(batch.mutations[0].fragment_count, Some(1));
4167        assert_eq!(
4168            alice
4169                .compose_text(address(&bob_identity), 78, "second".to_owned())
4170                .await,
4171            Err(MobileMeshError::OperationInProgress)
4172        );
4173        assert!(alice.poll_update().outbound_frames.is_empty());
4174        assert!(
4175            !alice_root.exists(),
4176            "compose alone must not touch counters"
4177        );
4178
4179        alice.commit_chat_batch(batch.batch_id).await.unwrap();
4180        assert!(alice_root.exists());
4181
4182        // First-contact counter synchronization plus the acknowledged fragment
4183        // pipeline can cross several scheduler ticks under loaded CI.
4184        let deadline = Instant::now() + Duration::from_secs(10);
4185        loop {
4186            let alice_update = alice.poll_update();
4187            for frame in alice_update.outbound_frames {
4188                alice.complete_outbound_frame(frame.id, true).unwrap();
4189                bob.receive(MobileMeshRxRecord {
4190                    data: frame.data,
4191                    rssi_dbm: Some(-55),
4192                    lqi: Some(200),
4193                    snr_cb: Some(70),
4194                })
4195                .unwrap();
4196            }
4197            let bob_update = bob.poll_update();
4198            for frame in bob_update.outbound_frames.iter().cloned() {
4199                bob.complete_outbound_frame(frame.id, true).unwrap();
4200                alice
4201                    .receive(MobileMeshRxRecord {
4202                        data: frame.data,
4203                        rssi_dbm: Some(-55),
4204                        lqi: Some(200),
4205                        snr_cb: Some(70),
4206                    })
4207                    .unwrap();
4208            }
4209            if let Some(mutation) = bob_update.chat_mutations.first() {
4210                assert_eq!(mutation.body.as_deref(), Some("hello from Rust"));
4211                assert_eq!(
4212                    mutation.sender_address.as_deref(),
4213                    Some(alice_address.as_str())
4214                );
4215                assert_eq!(
4216                    mutation.direction,
4217                    Some(crate::MobileChatDirection::Inbound)
4218                );
4219                let batch_id = bob_update.chat_batch_id.expect("owned chat batch");
4220                assert_eq!(
4221                    bob.poll_update().chat_batch_id,
4222                    Some(batch_id),
4223                    "unacknowledged chat effects must be replayed"
4224                );
4225                bob.acknowledge_chat_batch(batch_id).unwrap();
4226                assert!(bob.poll_update().chat_mutations.is_empty());
4227                break;
4228            }
4229            assert!(Instant::now() < deadline, "chat frame did not arrive");
4230            std::thread::sleep(Duration::from_millis(5));
4231        }
4232    }
4233
4234    #[tokio::test]
4235    async fn fragmented_chat_message_crosses_mobile_radio_bridge() {
4236        let directory = tempfile::tempdir().unwrap();
4237        let alice_identity = identity(31);
4238        let bob_identity = identity(32);
4239        let alice_store =
4240            MobileCounterStore::new(directory.path().join("long-alice").display().to_string())
4241                .unwrap();
4242        let bob_store =
4243            MobileCounterStore::new(directory.path().join("long-bob").display().to_string())
4244                .unwrap();
4245        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
4246            .await
4247            .unwrap();
4248        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
4249            .await
4250            .unwrap();
4251        let alice_address = address(&alice_identity);
4252        let bob_address = address(&bob_identity);
4253        alice
4254            .register_peers(vec![bob_address.clone()])
4255            .await
4256            .unwrap();
4257        bob.register_peers(vec![alice_address.clone()])
4258            .await
4259            .unwrap();
4260
4261        let body = "fragmented mobile message ".repeat(16);
4262        let batch = alice
4263            .compose_text(bob_address, 91, body.clone())
4264            .await
4265            .unwrap();
4266        let fragment_count = usize::from(batch.mutations[0].fragment_count.unwrap_or(1));
4267        assert!(fragment_count > 1);
4268        alice.commit_chat_batch(batch.batch_id).await.unwrap();
4269
4270        // First-contact counter synchronization plus the acknowledged fragment
4271        // pipeline can cross several scheduler ticks under loaded CI.
4272        let deadline = Instant::now() + Duration::from_secs(10);
4273        let mut outbound_lengths = Vec::new();
4274        let mut return_lengths = Vec::new();
4275        let mut receiver_complete = false;
4276        let mut sender_delivered = false;
4277        loop {
4278            let alice_update = alice.poll_update();
4279            let alice_frames = alice_update.outbound_frames;
4280            assert!(
4281                alice_frames.len() <= 1,
4282                "the mobile bridge must wait for physical TX completion"
4283            );
4284            for frame in alice_frames {
4285                outbound_lengths.push(frame.data.len());
4286                alice.complete_outbound_frame(frame.id, true).unwrap();
4287                bob.receive(MobileMeshRxRecord {
4288                    data: frame.data,
4289                    rssi_dbm: Some(-55),
4290                    lqi: Some(200),
4291                    snr_cb: Some(70),
4292                })
4293                .unwrap();
4294            }
4295            sender_delivered |= alice_update
4296                .chat_deliveries
4297                .iter()
4298                .any(|delivery| delivery.state == MobileChatDeliveryState::Acknowledged);
4299            if let Some(batch_id) = alice_update.chat_batch_id {
4300                alice.acknowledge_chat_batch(batch_id).unwrap();
4301            }
4302            let bob_update = bob.poll_update();
4303            for frame in bob_update.outbound_frames.iter().cloned() {
4304                return_lengths.push(frame.data.len());
4305                bob.complete_outbound_frame(frame.id, true).unwrap();
4306                alice
4307                    .receive(MobileMeshRxRecord {
4308                        data: frame.data,
4309                        rssi_dbm: Some(-55),
4310                        lqi: Some(200),
4311                        snr_cb: Some(70),
4312                    })
4313                    .unwrap();
4314            }
4315            if let Some(mutation) = bob_update
4316                .chat_mutations
4317                .iter()
4318                .find(|mutation| mutation.complete == Some(true))
4319            {
4320                assert_eq!(mutation.body.as_deref(), Some(body.as_str()));
4321                receiver_complete = true;
4322            }
4323            if let Some(batch_id) = bob_update.chat_batch_id {
4324                bob.acknowledge_chat_batch(batch_id).unwrap();
4325            }
4326            if receiver_complete && sender_delivered {
4327                assert!(
4328                    outbound_lengths.len() <= fragment_count * 2 + 4,
4329                    "fragment delivery was unexpectedly amplified: {outbound_lengths:?}"
4330                );
4331                break;
4332            }
4333            assert!(
4334                Instant::now() < deadline,
4335                "fragmented chat did not complete at both endpoints; receiver_complete={receiver_complete}, sender_delivered={sender_delivered}, outbound lengths: {outbound_lengths:?}; return lengths: {return_lengths:?}"
4336            );
4337            std::thread::sleep(Duration::from_millis(5));
4338        }
4339    }
4340
4341    #[tokio::test]
4342    async fn ulcp_link_failure_terminates_pending_chat_delivery() {
4343        let directory = tempfile::tempdir().unwrap();
4344        let local_identity = identity(41);
4345        let peer_identity = identity(42);
4346        let store =
4347            MobileCounterStore::new(directory.path().join("failed-send").display().to_string())
4348                .unwrap();
4349        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
4350        session
4351            .register_peers(vec![address(&peer_identity)])
4352            .await
4353            .unwrap();
4354        let batch = session
4355            .compose_text(address(&peer_identity), 17, "will fail".into())
4356            .await
4357            .unwrap();
4358        session.commit_chat_batch(batch.batch_id).await.unwrap();
4359        session.fail_outbound_transmissions().unwrap();
4360
4361        let deadline = Instant::now() + Duration::from_secs(2);
4362        loop {
4363            let update = session.poll_update();
4364            if update
4365                .chat_deliveries
4366                .iter()
4367                .any(|delivery| delivery.state == MobileChatDeliveryState::Failed)
4368            {
4369                break;
4370            }
4371            if let Some(batch_id) = update.chat_batch_id {
4372                session.acknowledge_chat_batch(batch_id).unwrap();
4373            }
4374            assert!(
4375                Instant::now() < deadline,
4376                "link failure did not terminate chat delivery"
4377            );
4378            std::thread::sleep(Duration::from_millis(5));
4379        }
4380    }
4381
4382    /// A ULCP-link failure declared while one fragment awaits physical
4383    /// TX completion must also stop the fragments queued behind it in the
4384    /// MAC: without the poisoned window, the drain loop keeps handing them
4385    /// to the platform after `fail_all` bumps the generation, so a single
4386    /// BLE hiccup fans out into several wasted physical transmissions.
4387    #[tokio::test]
4388    async fn mid_batch_failure_suppresses_fragments_queued_behind_the_blocked_one() {
4389        let directory = tempfile::tempdir().unwrap();
4390        let alice_identity = identity(51);
4391        let bob_identity = identity(52);
4392        let alice_store =
4393            MobileCounterStore::new(directory.path().join("mid-alice").display().to_string())
4394                .unwrap();
4395        let bob_store =
4396            MobileCounterStore::new(directory.path().join("mid-bob").display().to_string())
4397                .unwrap();
4398        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
4399            .await
4400            .unwrap();
4401        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
4402            .await
4403            .unwrap();
4404        let bob_address = address(&bob_identity);
4405        alice
4406            .register_peers(vec![bob_address.clone()])
4407            .await
4408            .unwrap();
4409        bob.register_peers(vec![address(&alice_identity)])
4410            .await
4411            .unwrap();
4412
4413        // Warmup: one acknowledged message opens the fragment pipeline, so a
4414        // later multi-fragment commit enqueues every fragment into the MAC.
4415        let warmup = alice
4416            .compose_text(bob_address.clone(), 1, "warmup".to_owned())
4417            .await
4418            .unwrap();
4419        alice.commit_chat_batch(warmup.batch_id).await.unwrap();
4420        let deadline = Instant::now() + Duration::from_secs(10);
4421        loop {
4422            let alice_update = alice.poll_update();
4423            for frame in alice_update.outbound_frames {
4424                alice.complete_outbound_frame(frame.id, true).unwrap();
4425                bob.receive(MobileMeshRxRecord {
4426                    data: frame.data,
4427                    rssi_dbm: Some(-50),
4428                    lqi: None,
4429                    snr_cb: Some(80),
4430                })
4431                .unwrap();
4432            }
4433            let acked = alice_update
4434                .chat_deliveries
4435                .iter()
4436                .any(|delivery| delivery.state == MobileChatDeliveryState::Acknowledged);
4437            if let Some(batch_id) = alice_update.chat_batch_id {
4438                alice.acknowledge_chat_batch(batch_id).unwrap();
4439            }
4440            let bob_update = bob.poll_update();
4441            for frame in bob_update.outbound_frames.iter().cloned() {
4442                bob.complete_outbound_frame(frame.id, true).unwrap();
4443                alice
4444                    .receive(MobileMeshRxRecord {
4445                        data: frame.data,
4446                        rssi_dbm: Some(-50),
4447                        lqi: None,
4448                        snr_cb: Some(80),
4449                    })
4450                    .unwrap();
4451            }
4452            if let Some(batch_id) = bob_update.chat_batch_id {
4453                bob.acknowledge_chat_batch(batch_id).unwrap();
4454            }
4455            if acked {
4456                break;
4457            }
4458            assert!(Instant::now() < deadline, "warmup exchange never acked");
4459            std::thread::sleep(Duration::from_millis(5));
4460        }
4461
4462        // Fragmented message: all fragments enter the MAC queue; the drain
4463        // blocks on the first fragment's physical completion.
4464        let body = "storm test payload ".repeat(24);
4465        let batch = alice
4466            .compose_text(bob_address, 2, body.clone())
4467            .await
4468            .unwrap();
4469        assert!(batch.mutations[0].fragment_count.unwrap_or(1) > 1);
4470        alice.commit_chat_batch(batch.batch_id).await.unwrap();
4471
4472        // Wait for the first fragment to reach the platform (the worker is
4473        // now blocked awaiting its completion), then declare link failure
4474        // without completing it.
4475        let deadline = Instant::now() + Duration::from_secs(10);
4476        loop {
4477            let update = alice.poll_update();
4478            if let Some(batch_id) = update.chat_batch_id {
4479                alice.acknowledge_chat_batch(batch_id).unwrap();
4480            }
4481            if !update.outbound_frames.is_empty() {
4482                break;
4483            }
4484            assert!(
4485                Instant::now() < deadline,
4486                "first fragment never reached the platform"
4487            );
4488            std::thread::sleep(Duration::from_millis(5));
4489        }
4490        let fail_at = Instant::now();
4491        alice.fail_outbound_transmissions().unwrap();
4492
4493        // The queued fragments behind the blocked one must not surface as
4494        // new platform transmissions, and every fragment must fail promptly
4495        // (the failure report must not wait out MAC listen/ack windows).
4496        let mut saw_failed = false;
4497        let quiet_deadline = fail_at + Duration::from_millis(1_000);
4498        while Instant::now() < quiet_deadline {
4499            let update = alice.poll_update();
4500            assert!(
4501                update.outbound_frames.is_empty(),
4502                "fragments queued behind a failed batch were still dispatched"
4503            );
4504            saw_failed |= update
4505                .chat_deliveries
4506                .iter()
4507                .any(|delivery| delivery.state == MobileChatDeliveryState::Failed);
4508            if let Some(batch_id) = update.chat_batch_id {
4509                alice.acknowledge_chat_batch(batch_id).unwrap();
4510            }
4511            std::thread::sleep(Duration::from_millis(10));
4512        }
4513        assert!(saw_failed, "batch failure never reported to the transcript");
4514
4515        // Recovery: once the cancellation is processed, new sends flow again.
4516        let retry = alice
4517            .compose_text(address(&bob_identity), 3, "after failure".to_owned())
4518            .await
4519            .unwrap();
4520        alice.commit_chat_batch(retry.batch_id).await.unwrap();
4521        let deadline = Instant::now() + Duration::from_secs(10);
4522        loop {
4523            let update = alice.poll_update();
4524            if let Some(batch_id) = update.chat_batch_id {
4525                alice.acknowledge_chat_batch(batch_id).unwrap();
4526            }
4527            if !update.outbound_frames.is_empty() {
4528                break;
4529            }
4530            assert!(
4531                Instant::now() < deadline,
4532                "transmissions never resumed after failure recovery"
4533            );
4534            std::thread::sleep(Duration::from_millis(5));
4535        }
4536    }
4537
4538    /// A group message crosses two real sessions over a shared channel key.
4539    ///
4540    /// Multicast has no acknowledgement, so the sender's terminal state is
4541    /// `Sent`; the receiver attributes the message to a claimed hint and,
4542    /// because group sends carry the full source, resolves that hint to a
4543    /// real address it can name.
4544    #[tokio::test]
4545    async fn a_channel_group_message_crosses_two_sessions() {
4546        let directory = tempfile::tempdir().unwrap();
4547        let alice_identity = identity(61);
4548        let bob_identity = identity(62);
4549        let alice = MobileMeshSession::new(
4550            alice_identity.clone(),
4551            MobileCounterStore::new(directory.path().join("ch-alice").display().to_string())
4552                .unwrap(),
4553        )
4554        .await
4555        .unwrap();
4556        let bob = MobileMeshSession::new(
4557            bob_identity.clone(),
4558            MobileCounterStore::new(directory.path().join("ch-bob").display().to_string()).unwrap(),
4559        )
4560        .await
4561        .unwrap();
4562
4563        let key = vec![0x5Cu8; 32];
4564        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
4565        assert!(conversation.starts_with("ch:"));
4566        alice.register_channels(vec![key.clone()]).await.unwrap();
4567        bob.register_channels(vec![key]).await.unwrap();
4568
4569        let batch = alice
4570            .compose_text(conversation.clone(), 1, "regroup at the ridge".to_owned())
4571            .await
4572            .unwrap();
4573        assert_eq!(batch.checkpoint.conversation_address, conversation);
4574        alice.commit_chat_batch(batch.batch_id).await.unwrap();
4575
4576        let mut alice_states = Vec::new();
4577        let mut received: Option<MobileChatMutationRecord> = None;
4578        let mut resolution: Option<MobileChatSenderResolutionRecord> = None;
4579        // The sender's delivery state lands on a later protocol tick than the
4580        // receiver's transcript, so all three are waited for together.
4581        let deadline = Instant::now() + Duration::from_secs(10);
4582        while received.is_none()
4583            || resolution.is_none()
4584            || !alice_states.contains(&MobileChatDeliveryState::Sent)
4585        {
4586            let alice_update = alice.poll_update();
4587            for frame in alice_update.outbound_frames {
4588                alice.complete_outbound_frame(frame.id, true).unwrap();
4589                bob.receive(MobileMeshRxRecord {
4590                    data: frame.data,
4591                    rssi_dbm: Some(-70),
4592                    lqi: None,
4593                    snr_cb: Some(60),
4594                })
4595                .unwrap();
4596            }
4597            alice_states.extend(
4598                alice_update
4599                    .chat_deliveries
4600                    .iter()
4601                    .map(|delivery| delivery.state),
4602            );
4603            if let Some(batch_id) = alice_update.chat_batch_id {
4604                alice.acknowledge_chat_batch(batch_id).unwrap();
4605            }
4606
4607            let bob_update = bob.poll_update();
4608            for frame in bob_update.outbound_frames {
4609                bob.complete_outbound_frame(frame.id, true).unwrap();
4610            }
4611            if let Some(record) = bob_update
4612                .chat_mutations
4613                .iter()
4614                .find(|mutation| mutation.body.as_deref() == Some("regroup at the ridge"))
4615            {
4616                received = Some(record.clone());
4617            }
4618            if let Some(record) = bob_update.chat_sender_resolutions.first() {
4619                resolution = Some(record.clone());
4620            }
4621            if let Some(batch_id) = bob_update.chat_batch_id {
4622                bob.acknowledge_chat_batch(batch_id).unwrap();
4623            }
4624            assert!(
4625                Instant::now() < deadline,
4626                "group message incomplete (mutation: {}, resolution: {}, states: {alice_states:?})",
4627                received.is_some(),
4628                resolution.is_some()
4629            );
4630            std::thread::sleep(Duration::from_millis(5));
4631        }
4632
4633        let received = received.unwrap();
4634        assert_eq!(
4635            received.conversation_address.as_deref(),
4636            Some(&conversation[..])
4637        );
4638        assert_eq!(received.direction, Some(MobileChatDirection::Inbound));
4639        // The hint is what the wire carried; the address is what the full
4640        // source let the facade resolve it to.
4641        assert_eq!(
4642            received.sender_hint.as_deref(),
4643            Some(&hint_of(&alice_identity)[..])
4644        );
4645        assert_eq!(
4646            received.sender_address.as_deref(),
4647            Some(&address(&alice_identity)[..])
4648        );
4649        let rx = received
4650            .rx
4651            .expect("a received frame carries radio metadata");
4652        assert_eq!(rx.rssi_dbm, Some(-70));
4653        assert_eq!(rx.snr_centibels, Some(60));
4654        // Heard directly off the air: no repeater carried it, so nothing
4655        // accumulated.
4656        assert_eq!(rx.hop_count, Some(0));
4657
4658        let resolution = resolution.unwrap();
4659        assert_eq!(resolution.conversation_address, conversation);
4660        assert_eq!(resolution.sender_hint, hint_of(&alice_identity));
4661        assert_eq!(resolution.sender_address, address(&alice_identity));
4662
4663        // Nothing acknowledges a multicast, so `Sent` is where it ends.
4664        assert!(alice_states.contains(&MobileChatDeliveryState::Sent));
4665        assert!(!alice_states.contains(&MobileChatDeliveryState::Acknowledged));
4666    }
4667
4668    /// `EMERGENCY` chat goes out readable, and unreadable copies are ignored.
4669    ///
4670    /// The two halves are one rule seen from both ends, so they are proven
4671    /// together: what leaves carries no encryption, and a frame that arrives
4672    /// encrypted is refused however well it authenticates. The refused frame
4673    /// here is byte-for-byte the payload the accepted one carries, sealed
4674    /// under the same channel key by the same sender — encryption is the only
4675    /// difference between the message that is shown and the message that is
4676    /// not.
4677    #[tokio::test]
4678    async fn emergency_chat_is_sent_readable_and_encrypted_copies_are_refused() {
4679        use umsh_core::{MicSize, PacketBuilder, PacketHeader};
4680        use umsh_crypto::{
4681            CryptoEngine, PairwiseKeys,
4682            software::{SoftwareAes, SoftwareSha256},
4683        };
4684
4685        let directory = tempfile::tempdir().unwrap();
4686        let alice_identity = identity(71);
4687        let alice = MobileMeshSession::new(
4688            alice_identity.clone(),
4689            MobileCounterStore::new(directory.path().join("sos-alice").display().to_string())
4690                .unwrap(),
4691        )
4692        .await
4693        .unwrap();
4694        let bob = MobileMeshSession::new(
4695            identity(72),
4696            MobileCounterStore::new(directory.path().join("sos-bob").display().to_string())
4697                .unwrap(),
4698        )
4699        .await
4700        .unwrap();
4701
4702        let key = crate::inspect_channel_name(crate::EMERGENCY_CHANNEL_NAME.to_owned())
4703            .unwrap()
4704            .key;
4705        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
4706        alice.register_channels(vec![key.clone()]).await.unwrap();
4707        bob.register_channels(vec![key.clone()]).await.unwrap();
4708
4709        let body = "tower down at mile 14";
4710        let batch = alice
4711            .compose_text(conversation.clone(), 1, body.to_owned())
4712            .await
4713            .unwrap();
4714        alice.commit_chat_batch(batch.batch_id).await.unwrap();
4715
4716        // Collect what Alice puts on the air. A message this short is one
4717        // frame; anything else the session emits is not a multicast on this
4718        // channel and is filtered out below.
4719        let engine = CryptoEngine::new(SoftwareAes, SoftwareSha256);
4720        let channel_keys =
4721            engine.derive_channel_keys(&crate::channel_key_from_bytes(&key).unwrap());
4722        let deadline = Instant::now() + Duration::from_secs(10);
4723        let mut frame = None;
4724        while frame.is_none() {
4725            for outbound in alice.poll_update().outbound_frames {
4726                alice.complete_outbound_frame(outbound.id, true).unwrap();
4727                let header = match PacketHeader::parse(&outbound.data) {
4728                    Ok(header) => header,
4729                    Err(_) => continue,
4730                };
4731                if header.channel == Some(channel_keys.channel_id)
4732                    && header.packet_type() == umsh_core::PacketType::Multicast
4733                {
4734                    frame = Some((outbound.data, header));
4735                }
4736            }
4737            assert!(Instant::now() < deadline, "no emergency frame was sent");
4738            std::thread::sleep(Duration::from_millis(5));
4739        }
4740        let (frame, header) = frame.unwrap();
4741
4742        // Half one: it left in the clear.
4743        let sec_info = header.sec_info.expect("a multicast frame carries SECINFO");
4744        assert!(
4745            !sec_info.scf.encrypted(),
4746            "emergency chat must be readable by any node in range"
4747        );
4748        assert!(
4749            matches!(header.source, umsh_core::SourceAddrRef::FullKeyAt { .. }),
4750            "emergency chat must name its sender outright"
4751        );
4752
4753        // Half two: the same payload, from the same sender, under the same
4754        // channel key — encrypted. It authenticates perfectly and must still
4755        // be refused. An earlier frame counter keeps it ahead of the real
4756        // frame in the channel's replay window, so the genuine copy that
4757        // follows is judged on its own merits.
4758        let payload = {
4759            let mut opened = frame.clone();
4760            let range = engine
4761                .open_packet(
4762                    &mut opened,
4763                    &header,
4764                    &PairwiseKeys {
4765                        k_enc: channel_keys.k_enc,
4766                        k_mic: channel_keys.k_mic,
4767                    },
4768                )
4769                .unwrap();
4770            opened[range].to_vec()
4771        };
4772        assert!(
4773            sec_info.frame_counter > 0,
4774            "the forged copy needs a lower counter than the genuine one"
4775        );
4776        let alice_key = decode_peer(&address(&alice_identity)).unwrap();
4777        let mut buf = [0u8; 256];
4778        let mut forged = PacketBuilder::new(&mut buf)
4779            .multicast(channel_keys.channel_id)
4780            .source_full(&alice_key)
4781            .frame_counter(sec_info.frame_counter - 1)
4782            .encrypted()
4783            .mic_size(MicSize::Mic16)
4784            .payload(&payload)
4785            .build()
4786            .unwrap();
4787        engine
4788            .seal_packet(
4789                &mut forged,
4790                &PairwiseKeys {
4791                    k_enc: channel_keys.k_enc,
4792                    k_mic: channel_keys.k_mic,
4793                },
4794            )
4795            .unwrap();
4796        bob.receive(MobileMeshRxRecord {
4797            data: forged.as_bytes().to_vec(),
4798            rssi_dbm: Some(-70),
4799            lqi: None,
4800            snr_cb: Some(60),
4801        })
4802        .unwrap();
4803
4804        let mut refusal = None;
4805        let deadline = Instant::now() + Duration::from_secs(10);
4806        while refusal.is_none() {
4807            let update = bob.poll_update();
4808            assert!(
4809                !update
4810                    .chat_mutations
4811                    .iter()
4812                    .any(|mutation| mutation.body.as_deref() == Some(body)),
4813                "an encrypted emergency frame reached the transcript"
4814            );
4815            refusal = update
4816                .chat_diagnostics
4817                .iter()
4818                .find(|line| line.contains("emergency-channel"))
4819                .cloned();
4820            if let Some(batch_id) = update.chat_batch_id {
4821                bob.acknowledge_chat_batch(batch_id).unwrap();
4822            }
4823            assert!(
4824                Instant::now() < deadline,
4825                "the encrypted copy was not refused"
4826            );
4827            std::thread::sleep(Duration::from_millis(5));
4828        }
4829        assert!(refusal.unwrap().contains("encrypted"));
4830
4831        // And the genuine one, differing only in that it is readable, lands.
4832        bob.receive(MobileMeshRxRecord {
4833            data: frame,
4834            rssi_dbm: Some(-70),
4835            lqi: None,
4836            snr_cb: Some(60),
4837        })
4838        .unwrap();
4839        let deadline = Instant::now() + Duration::from_secs(10);
4840        let mut received = false;
4841        while !received {
4842            let update = bob.poll_update();
4843            received = update
4844                .chat_mutations
4845                .iter()
4846                .any(|mutation| mutation.body.as_deref() == Some(body));
4847            if let Some(batch_id) = update.chat_batch_id {
4848                bob.acknowledge_chat_batch(batch_id).unwrap();
4849            }
4850            assert!(Instant::now() < deadline, "the readable copy never arrived");
4851            std::thread::sleep(Duration::from_millis(5));
4852        }
4853    }
4854
4855    /// Repair still works once emergency traffic stops being encrypted.
4856    ///
4857    /// A resend request goes out channel-addressed and, on `EMERGENCY`, in
4858    /// the clear, so it is a frame the receiving gate now judges: were the
4859    /// two halves of the rule out of step, a dropped fragment there could
4860    /// never be recovered and a long emergency message would never assemble.
4861    ///
4862    /// It also covers group repair as such, which nothing else does: it is
4863    /// the only test where a member has to ask for a fragment and get it.
4864    /// Two separate faults used to stop that dead — the requester could not
4865    /// address a channel member it had never registered as a peer, and the
4866    /// sender refused to serve any frame still sitting in `in_flight`, which
4867    /// a multicast never left. Either one alone leaves this failing.
4868    ///
4869    /// Runs on the real clock, and takes the repair grace period in real
4870    /// seconds because of it. Virtual time is faster but not usable here:
4871    /// the runtime leaps to the next deadline whenever it is idle, and a
4872    /// test that drives it from outside idles constantly, so under load the
4873    /// reassembly can age out its whole 90-second lifetime between two
4874    /// polls.
4875    #[tokio::test]
4876    async fn a_dropped_emergency_fragment_is_repaired() {
4877        let directory = tempfile::tempdir().unwrap();
4878        let alice = MobileMeshSession::new(
4879            identity(73),
4880            MobileCounterStore::new(directory.path().join("sos-frag-a").display().to_string())
4881                .unwrap(),
4882        )
4883        .await
4884        .unwrap();
4885        let bob = MobileMeshSession::new(
4886            identity(74),
4887            MobileCounterStore::new(directory.path().join("sos-frag-b").display().to_string())
4888                .unwrap(),
4889        )
4890        .await
4891        .unwrap();
4892
4893        let key = crate::inspect_channel_name(crate::EMERGENCY_CHANNEL_NAME.to_owned())
4894            .unwrap()
4895            .key;
4896        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
4897        alice.register_channels(vec![key.clone()]).await.unwrap();
4898        bob.register_channels(vec![key]).await.unwrap();
4899
4900        let body: String = (0..600)
4901            .map(|index| char::from(b'a' + (index % 26) as u8))
4902            .collect();
4903        let batch = alice
4904            .compose_text(conversation.clone(), 1, body.clone())
4905            .await
4906            .unwrap();
4907        assert!(batch.mutations[0].fragment_count.unwrap() > 1);
4908        // Alice's own archive, as the platform would keep it: the only thing
4909        // she can answer a resend request out of.
4910        let archives: std::collections::HashMap<(u8, Option<u8>), Vec<u8>> = batch
4911            .archives
4912            .iter()
4913            .map(|archive| {
4914                (
4915                    (archive.message_id, archive.fragment_index),
4916                    archive.payload.clone(),
4917                )
4918            })
4919            .collect();
4920        alice.commit_chat_batch(batch.batch_id).await.unwrap();
4921
4922        let mut sent = 0;
4923        let mut repairs = 0;
4924        let mut assembled: Option<String> = None;
4925        let deadline = Instant::now() + Duration::from_secs(40);
4926        while assembled.as_deref() != Some(body.as_str()) {
4927            let alice_update = alice.poll_update();
4928            for frame in alice_update.outbound_frames {
4929                alice.complete_outbound_frame(frame.id, true).unwrap();
4930                sent += 1;
4931                // The radio eats the second fragment. Everything after it
4932                // gets through, so only a repair can complete the message.
4933                if sent == 2 {
4934                    continue;
4935                }
4936                bob.receive(MobileMeshRxRecord {
4937                    data: frame.data,
4938                    rssi_dbm: Some(-70),
4939                    lqi: None,
4940                    snr_cb: Some(60),
4941                })
4942                .unwrap();
4943            }
4944            for lookup in &alice_update.chat_archive_lookups {
4945                match archives.get(&(lookup.message_id, lookup.fragment_index)) {
4946                    Some(payload) => alice
4947                        .apply_chat_archive_result(
4948                            lookup.request_id,
4949                            MobileChatArchiveResultKind::Found,
4950                            payload.clone(),
4951                        )
4952                        .unwrap(),
4953                    None => alice
4954                        .apply_chat_archive_result(
4955                            lookup.request_id,
4956                            MobileChatArchiveResultKind::Unknown,
4957                            Vec::new(),
4958                        )
4959                        .unwrap(),
4960                }
4961            }
4962            if let Some(batch_id) = alice_update.chat_batch_id {
4963                alice.acknowledge_chat_batch(batch_id).unwrap();
4964            }
4965
4966            let bob_update = bob.poll_update();
4967            for frame in bob_update.outbound_frames {
4968                bob.complete_outbound_frame(frame.id, true).unwrap();
4969                repairs += 1;
4970                alice
4971                    .receive(MobileMeshRxRecord {
4972                        data: frame.data,
4973                        rssi_dbm: Some(-70),
4974                        lqi: None,
4975                        snr_cb: Some(60),
4976                    })
4977                    .unwrap();
4978            }
4979            for mutation in &bob_update.chat_mutations {
4980                if let Some(text) = mutation.body.as_deref() {
4981                    assembled = Some(text.to_owned());
4982                }
4983            }
4984            if let Some(batch_id) = bob_update.chat_batch_id {
4985                bob.acknowledge_chat_batch(batch_id).unwrap();
4986            }
4987            assert!(
4988                Instant::now() < deadline,
4989                "a dropped emergency fragment was never repaired \
4990                 ({repairs} repair frame(s), assembled {:?})",
4991                assembled.as_ref().map(|text| text.len())
4992            );
4993            std::thread::sleep(Duration::from_millis(5));
4994        }
4995        assert!(repairs > 0, "the message assembled without any repair");
4996    }
4997
4998    /// A repeater's copy of our own group message is not a second message.
4999    ///
5000    /// Every multicast send carries our full source address so strangers can
5001    /// address repairs to us, which means a relayed copy comes back naming us
5002    /// as the sender. Feeding that to the transcript would show the user
5003    /// their own message twice — once as sent, once as received from
5004    /// themselves.
5005    #[tokio::test]
5006    async fn a_relayed_copy_of_our_own_group_message_is_not_transcribed() {
5007        let directory = tempfile::tempdir().unwrap();
5008        let identity = identity(67);
5009        let session = MobileMeshSession::new(
5010            identity.clone(),
5011            MobileCounterStore::new(directory.path().join("echo").display().to_string()).unwrap(),
5012        )
5013        .await
5014        .unwrap();
5015
5016        let key = vec![0x3Eu8; 32];
5017        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
5018        session.register_channels(vec![key]).await.unwrap();
5019
5020        let batch = session
5021            .compose_text(conversation.clone(), 1, "anyone out there".to_owned())
5022            .await
5023            .unwrap();
5024        session.commit_chat_batch(batch.batch_id).await.unwrap();
5025
5026        // Feed every frame the session emits straight back into it, which is
5027        // exactly what a repeater in range does.
5028        let deadline = Instant::now() + Duration::from_secs(10);
5029        let mut echoed = 0;
5030        let mut inbound = Vec::new();
5031        while echoed == 0
5032            || Instant::now() < deadline.min(Instant::now() + Duration::from_millis(1))
5033        {
5034            let update = session.poll_update();
5035            for frame in update.outbound_frames {
5036                session.complete_outbound_frame(frame.id, true).unwrap();
5037                session
5038                    .receive(MobileMeshRxRecord {
5039                        data: frame.data,
5040                        rssi_dbm: Some(-60),
5041                        lqi: None,
5042                        snr_cb: Some(70),
5043                    })
5044                    .unwrap();
5045                echoed += 1;
5046            }
5047            inbound.extend(
5048                update
5049                    .chat_mutations
5050                    .iter()
5051                    .filter(|mutation| mutation.direction == Some(MobileChatDirection::Inbound))
5052                    .cloned(),
5053            );
5054            if let Some(batch_id) = update.chat_batch_id {
5055                session.acknowledge_chat_batch(batch_id).unwrap();
5056            }
5057            if echoed > 0 && Instant::now() > deadline {
5058                break;
5059            }
5060            std::thread::sleep(Duration::from_millis(5));
5061            if echoed > 0 {
5062                // Give the echo every chance to be (wrongly) transcribed.
5063                for _ in 0..20 {
5064                    let update = session.poll_update();
5065                    inbound.extend(
5066                        update
5067                            .chat_mutations
5068                            .iter()
5069                            .filter(|mutation| {
5070                                mutation.direction == Some(MobileChatDirection::Inbound)
5071                            })
5072                            .cloned(),
5073                    );
5074                    if let Some(batch_id) = update.chat_batch_id {
5075                        session.acknowledge_chat_batch(batch_id).unwrap();
5076                    }
5077                    std::thread::sleep(Duration::from_millis(5));
5078                }
5079                break;
5080            }
5081        }
5082
5083        assert!(echoed > 0, "the session never transmitted the message");
5084        assert!(
5085            inbound.is_empty(),
5086            "our own relayed message was transcribed as inbound: {inbound:?}"
5087        );
5088    }
5089
5090    /// Composing needs a channel this session actually holds: an address for
5091    /// an unregistered key, and an address for a channel that was left, are
5092    /// both refused rather than silently sent nowhere.
5093    #[tokio::test]
5094    async fn composing_to_an_unheld_channel_is_refused() {
5095        let directory = tempfile::tempdir().unwrap();
5096        let session = MobileMeshSession::new(
5097            identity(63),
5098            MobileCounterStore::new(directory.path().join("unheld").display().to_string()).unwrap(),
5099        )
5100        .await
5101        .unwrap();
5102
5103        let key = vec![0x77u8; 32];
5104        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
5105        assert_eq!(
5106            session
5107                .compose_text(conversation.clone(), 1, "hello".to_owned())
5108                .await,
5109            Err(MobileMeshError::UnknownConversation)
5110        );
5111
5112        session.register_channels(vec![key.clone()]).await.unwrap();
5113        let batch = session
5114            .compose_text(conversation.clone(), 2, "hello".to_owned())
5115            .await
5116            .expect("a joined channel composes");
5117        // Rejected rather than committed: this test is about which addresses
5118        // resolve, and an uncommitted batch would block the next compose.
5119        session
5120            .reject_chat_batch(batch.batch_id, Vec::new())
5121            .await
5122            .unwrap();
5123
5124        session.remove_channels(vec![key]).await.unwrap();
5125        assert_eq!(
5126            session
5127                .compose_text(conversation, 3, "hello".to_owned())
5128                .await,
5129            Err(MobileMeshError::UnknownConversation)
5130        );
5131    }
5132
5133    /// A malformed conversation address is rejected the same way, rather than
5134    /// being taken for a peer address and failing somewhere less obvious.
5135    #[tokio::test]
5136    async fn a_malformed_conversation_address_is_refused() {
5137        let directory = tempfile::tempdir().unwrap();
5138        let session = MobileMeshSession::new(
5139            identity(64),
5140            MobileCounterStore::new(directory.path().join("malformed").display().to_string())
5141                .unwrap(),
5142        )
5143        .await
5144        .unwrap();
5145        for address in ["ch:not-hex", "ch:0011", "definitely not base58 !!"] {
5146            assert_eq!(
5147                session
5148                    .compose_text(address.to_owned(), 1, "hello".to_owned())
5149                    .await,
5150                Err(MobileMeshError::UnknownConversation),
5151                "{address} should not resolve to a conversation"
5152            );
5153        }
5154    }
5155
5156    /// Direct chat keeps working, and now reports the radio metadata of the
5157    /// frame each inbound message arrived on.
5158    #[tokio::test]
5159    async fn direct_chat_still_delivers_and_now_carries_radio_metadata() {
5160        let directory = tempfile::tempdir().unwrap();
5161        let alice_identity = identity(65);
5162        let bob_identity = identity(66);
5163        let alice = MobileMeshSession::new(
5164            alice_identity.clone(),
5165            MobileCounterStore::new(directory.path().join("dm-alice").display().to_string())
5166                .unwrap(),
5167        )
5168        .await
5169        .unwrap();
5170        let bob = MobileMeshSession::new(
5171            bob_identity.clone(),
5172            MobileCounterStore::new(directory.path().join("dm-bob").display().to_string()).unwrap(),
5173        )
5174        .await
5175        .unwrap();
5176        let bob_address = address(&bob_identity);
5177        alice
5178            .register_peers(vec![bob_address.clone()])
5179            .await
5180            .unwrap();
5181        bob.register_peers(vec![address(&alice_identity)])
5182            .await
5183            .unwrap();
5184
5185        let batch = alice
5186            .compose_text(bob_address.clone(), 1, "still here".to_owned())
5187            .await
5188            .unwrap();
5189        assert_eq!(batch.checkpoint.conversation_address, bob_address);
5190        alice.commit_chat_batch(batch.batch_id).await.unwrap();
5191
5192        let deadline = Instant::now() + Duration::from_secs(10);
5193        let received = loop {
5194            let alice_update = alice.poll_update();
5195            for frame in alice_update.outbound_frames {
5196                alice.complete_outbound_frame(frame.id, true).unwrap();
5197                bob.receive(MobileMeshRxRecord {
5198                    data: frame.data,
5199                    rssi_dbm: Some(-55),
5200                    lqi: None,
5201                    snr_cb: Some(75),
5202                })
5203                .unwrap();
5204            }
5205            if let Some(batch_id) = alice_update.chat_batch_id {
5206                alice.acknowledge_chat_batch(batch_id).unwrap();
5207            }
5208            let bob_update = bob.poll_update();
5209            for frame in bob_update.outbound_frames {
5210                bob.complete_outbound_frame(frame.id, true).unwrap();
5211                alice
5212                    .receive(MobileMeshRxRecord {
5213                        data: frame.data,
5214                        rssi_dbm: Some(-55),
5215                        lqi: None,
5216                        snr_cb: Some(75),
5217                    })
5218                    .unwrap();
5219            }
5220            let found = bob_update
5221                .chat_mutations
5222                .iter()
5223                .find(|mutation| mutation.body.as_deref() == Some("still here"))
5224                .cloned();
5225            if let Some(batch_id) = bob_update.chat_batch_id {
5226                bob.acknowledge_chat_batch(batch_id).unwrap();
5227            }
5228            if let Some(found) = found {
5229                break found;
5230            }
5231            assert!(
5232                Instant::now() < deadline,
5233                "the direct message never arrived"
5234            );
5235            std::thread::sleep(Duration::from_millis(5));
5236        };
5237
5238        assert_eq!(
5239            received.conversation_address.as_deref(),
5240            Some(&address(&alice_identity)[..])
5241        );
5242        // A direct sender is individually authenticated, so there is no hint
5243        // standing in for an identity.
5244        assert_eq!(received.sender_hint, None);
5245        assert_eq!(
5246            received.sender_address.as_deref(),
5247            Some(&address(&alice_identity)[..])
5248        );
5249        let rx = received
5250            .rx
5251            .expect("a received frame carries radio metadata");
5252        assert_eq!(rx.rssi_dbm, Some(-55));
5253        assert_eq!(rx.snr_centibels, Some(75));
5254        assert!(rx.source_authenticated);
5255    }
5256
5257    /// A batch id is issued exactly when the batch has events in it, and never
5258    /// otherwise. The platform reads the id as its whole signal to apply and
5259    /// acknowledge, so a batch made of only one kind of event — a lone sender
5260    /// resolution, say — must still be announced. One batch left
5261    /// unacknowledged holds the slot for the rest of the session, and every
5262    /// delivery receipt behind it never arrives: messages transmit fine and
5263    /// stay on "Sending" forever, in every conversation at once.
5264    fn assert_batch_id_matches_events(update: &MobileMeshSessionUpdateRecord) {
5265        let has_events = !update.chat_mutations.is_empty()
5266            || !update.chat_deliveries.is_empty()
5267            || !update.chat_archive_lookups.is_empty()
5268            || !update.chat_sender_resolutions.is_empty()
5269            || !update.chat_diagnostics.is_empty();
5270        assert_eq!(
5271            update.chat_batch_id.is_some(),
5272            has_events,
5273            "batch id {:?} disagrees with the batch's contents",
5274            update.chat_batch_id
5275        );
5276    }
5277
5278    /// A fragmented group message must arrive whole.
5279    ///
5280    /// Multicast is never acknowledged, so nothing downstream may treat an ack
5281    /// as the signal to release the next fragment: every fragment has to reach
5282    /// the air on transmission alone.
5283    #[tokio::test]
5284    async fn a_fragmented_channel_group_message_arrives_whole() {
5285        let directory = tempfile::tempdir().unwrap();
5286        let alice_identity = identity(71);
5287        let bob_identity = identity(72);
5288        let alice = MobileMeshSession::new(
5289            alice_identity.clone(),
5290            MobileCounterStore::new(directory.path().join("frag-alice").display().to_string())
5291                .unwrap(),
5292        )
5293        .await
5294        .unwrap();
5295        let bob = MobileMeshSession::new(
5296            bob_identity.clone(),
5297            MobileCounterStore::new(directory.path().join("frag-bob").display().to_string())
5298                .unwrap(),
5299        )
5300        .await
5301        .unwrap();
5302
5303        let key = vec![0x9Au8; 32];
5304        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
5305        alice.register_channels(vec![key.clone()]).await.unwrap();
5306        bob.register_channels(vec![key]).await.unwrap();
5307
5308        // Comfortably past a single frame, so the engine must fragment.
5309        let body: String = (0..600)
5310            .map(|index| char::from(b'a' + (index % 26) as u8))
5311            .collect();
5312        let batch = alice
5313            .compose_text(conversation.clone(), 1, body.clone())
5314            .await
5315            .unwrap();
5316        let fragments = batch.mutations[0].fragment_count.unwrap();
5317        assert!(
5318            fragments > 1,
5319            "the test body must fragment, got {fragments}"
5320        );
5321        alice.commit_chat_batch(batch.batch_id).await.unwrap();
5322
5323        let mut transmitted = 0;
5324        let mut repairs = 0;
5325        let mut assembled: Option<String> = None;
5326        let deadline = Instant::now() + Duration::from_secs(15);
5327        while assembled.as_deref() != Some(body.as_str()) {
5328            let alice_update = alice.poll_update();
5329            assert_batch_id_matches_events(&alice_update);
5330            for frame in alice_update.outbound_frames {
5331                alice.complete_outbound_frame(frame.id, true).unwrap();
5332                transmitted += 1;
5333                bob.receive(MobileMeshRxRecord {
5334                    data: frame.data,
5335                    rssi_dbm: Some(-70),
5336                    lqi: None,
5337                    snr_cb: Some(60),
5338                })
5339                .unwrap();
5340            }
5341            if let Some(batch_id) = alice_update.chat_batch_id {
5342                alice.acknowledge_chat_batch(batch_id).unwrap();
5343            }
5344
5345            let bob_update = bob.poll_update();
5346            assert_batch_id_matches_events(&bob_update);
5347            for frame in bob_update.outbound_frames {
5348                bob.complete_outbound_frame(frame.id, true).unwrap();
5349                // Bob has nothing to say on his own account: anything he
5350                // transmits is a request to have a fragment resent.
5351                repairs += 1;
5352                alice
5353                    .receive(MobileMeshRxRecord {
5354                        data: frame.data,
5355                        rssi_dbm: Some(-70),
5356                        lqi: None,
5357                        snr_cb: Some(60),
5358                    })
5359                    .unwrap();
5360            }
5361            for mutation in &bob_update.chat_mutations {
5362                if let Some(text) = mutation.body.as_deref() {
5363                    assembled = Some(text.to_owned());
5364                }
5365            }
5366            if let Some(batch_id) = bob_update.chat_batch_id {
5367                bob.acknowledge_chat_batch(batch_id).unwrap();
5368            }
5369            assert!(
5370                Instant::now() < deadline,
5371                "fragmented group message never completed \
5372                 ({transmitted} frame(s) transmitted of {fragments}, \
5373                 assembled {:?})",
5374                assembled.as_ref().map(|text| text.len())
5375            );
5376            std::thread::sleep(Duration::from_millis(5));
5377        }
5378
5379        // Every fragment reached the air off the original send. Had the
5380        // sender stalled waiting for an acknowledgement that a multicast
5381        // never produces, the message could only have completed through
5382        // Bob asking for the rest — so a repair here would mean the
5383        // transmit path is ack-gated even though the transcript recovered.
5384        assert!(
5385            transmitted >= usize::from(fragments),
5386            "only {transmitted} of {fragments} fragment(s) were transmitted"
5387        );
5388        assert_eq!(repairs, 0, "the message needed {repairs} repair request(s)");
5389    }
5390}