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::{
33    Clock, CounterStore, KeyValueStore, Radio, RxInfo, RxOrigin, Snr, TxError, TxOptions,
34};
35use umsh_mac::{Mac, MacHandle, OperatingPolicy, RepeaterConfig, SendOptions};
36use umsh_node::{
37    Host, LocalNode, MacBackend, NodeCapabilities, NodeIdentityPayload, NodeIdentityProfile,
38    NodeRole, PacketFamily, SendProgressTicket, Transport,
39    location::{MAX_PRECISION, NodeLocation},
40};
41use umsh_sync::AsyncRefCell;
42use umsh_text::engine::{ArchiveResult, DeliveryState, Destination};
43use umsh_text::model::{ConversationKey, SenderScope};
44use umsh_text::validate::{DeliveryPath, Envelope};
45use umsh_ulcp::{
46    frame,
47    ids::{self, prop},
48    items,
49};
50
51use crate::mobile_chat::{
52    ChannelRegistry, MobileChatArchiveLookupRecord, MobileChatArchiveResultKind,
53    MobileChatCheckpointRecord, MobileChatComposeBatchRecord, MobileChatDeliveryRecord,
54    MobileChatDirection, MobileChatMutationKind, MobileChatMutationRecord, MobileChatOriginalRef,
55    MobileChatPresence, MobileChatRegardingRef, MobileChatRxMetadataRecord,
56    MobileChatSenderResolutionRecord, MobileChatState,
57};
58use crate::ulcp::{UlcpPropertyFrameRecord, UlcpSyncRecord};
59use crate::{MobileCounterStore, MobileError, MobileIdentity};
60
61const MAX_FRAME_SIZE: usize = 256;
62const DEFAULT_FRAME_TIME_MS: u32 = 800;
63/// Flood-hop budget on a beacon. A beacon exists to publish a path, so it
64/// has to travel far enough for there to be a path worth publishing.
65const BEACON_FLOOD_HOPS: u8 = 5;
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Error)]
68pub enum MobileMeshError {
69    InvalidPeer,
70    SessionUnavailable,
71    OperationInProgress,
72    CounterPersistenceFailed,
73    SendFailed,
74    ChatComposeFailed,
75    ChatBatchMissing,
76    /// A channel key was not exactly 32 octets.
77    InvalidChannelKey,
78    /// The MAC's channel table is full.
79    ChannelCapacity,
80    /// The conversation address was malformed, or named a channel this
81    /// session holds no key for.
82    UnknownConversation,
83    /// A shared location did not name a place: a non-finite or
84    /// out-of-range coordinate, or a precision the cell code cannot
85    /// carry.
86    InvalidLocation,
87    /// A management request does not fit one Node Management payload, or
88    /// asked for nothing at all.
89    InvalidRequest,
90}
91
92impl fmt::Display for MobileMeshError {
93    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        formatter.write_str(match self {
95            Self::InvalidPeer => "MESH_INVALID_PEER",
96            Self::SessionUnavailable => "MESH_SESSION_UNAVAILABLE",
97            Self::OperationInProgress => "MESH_OPERATION_IN_PROGRESS",
98            Self::CounterPersistenceFailed => "MESH_COUNTER_PERSISTENCE_FAILED",
99            Self::SendFailed => "MESH_SEND_FAILED",
100            Self::ChatComposeFailed => "MESH_CHAT_COMPOSE_FAILED",
101            Self::ChatBatchMissing => "MESH_CHAT_BATCH_MISSING",
102            Self::InvalidChannelKey => "MESH_INVALID_CHANNEL_KEY",
103            Self::ChannelCapacity => "MESH_CHANNEL_CAPACITY",
104            Self::UnknownConversation => "MESH_UNKNOWN_CONVERSATION",
105            Self::InvalidLocation => "MESH_INVALID_LOCATION",
106            Self::InvalidRequest => "MESH_INVALID_REQUEST",
107        })
108    }
109}
110
111impl std::error::Error for MobileMeshError {}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
114pub enum MobileMeshPingOutcome {
115    Reply,
116    TimedOut,
117    Failed,
118}
119
120#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
121pub struct MobileMeshPingEventRecord {
122    pub operation_id: u64,
123    pub outcome: MobileMeshPingOutcome,
124    pub round_trip_milliseconds: Option<u64>,
125    /// Radio links the response crossed, counting the final one into this
126    /// device: a direct response is one hop. Absent on a reply that was
127    /// source-routed without a trace route — it crossed hops nobody recorded,
128    /// so no count is claimed.
129    pub hop_count: Option<u8>,
130    /// Authenticated intermediate-router hints, in source-to-destination order.
131    /// The two endpoints are not included.
132    pub route_hints: Vec<Vec<u8>>,
133    /// Signal measurements for the final radio hop into this device.
134    pub rssi_dbm: Option<i16>,
135    pub snr_centibels: Option<i16>,
136    pub lqi: Option<u8>,
137}
138
139/// How a Node Management operation ended.
140#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
141pub enum MobileMeshManagementOutcome {
142    /// Not an ending: the operation is still running. Emitted while a
143    /// device works through an answer larger than one frame, or while a
144    /// whole-device read crawls; `remaining_octets` is what the device
145    /// says it is still holding back.
146    Progress,
147    /// The device answered, and `answers` is what it said.
148    Replied,
149    /// A reset-class command, which a device answers with nothing at all.
150    /// The acknowledgment is the completion.
151    Acknowledged,
152    /// Nothing came back before the exchange gave up. Over LoRa this is
153    /// the ordinary shape of "out of range", not a malfunction.
154    TimedOut,
155    /// The operation could not be carried: an unroutable target, another
156    /// operation already outstanding, or an answer that could not be read.
157    /// A device that is not listing this phone as an administrator answers
158    /// with silence rather than a refusal, so it arrives as `TimedOut`.
159    Failed,
160}
161
162/// What occupied one position of a management answer.
163#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
164pub struct MobileMeshManagementAnswerRecord {
165    pub property_id: u32,
166    /// What the device reports the property is worth. A write is echoed
167    /// with the value the device actually kept, which is not always the
168    /// one that was sent.
169    pub value: Option<Vec<u8>>,
170    /// The status that stood in place of a value: refused, absent, or out
171    /// of an administrator's reach.
172    pub status_code: Option<u32>,
173}
174
175/// One report from an operation started with a `begin_management_*` call.
176///
177/// Several may arrive for one `operation_id`: any number of `Progress`
178/// reports, then exactly one ending.
179#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
180pub struct MobileMeshManagementEventRecord {
181    pub operation_id: u64,
182    /// Canonical Base58 address of the device being managed.
183    pub peer_address: String,
184    pub outcome: MobileMeshManagementOutcome,
185    /// The answers, in the order they were asked for.
186    pub answers: Vec<MobileMeshManagementAnswerRecord>,
187    /// The status when the device answered the whole exchange with one —
188    /// what a save or a whole-table write reports.
189    pub status_code: Option<u32>,
190    /// Octets the device has yet to return of the answer it is part-way
191    /// through, as of the last frame it sent.
192    pub remaining_octets: Option<u32>,
193    /// Properties a read has yet to ask for. Only `begin_management_fetch`
194    /// reports it, and only while it is running.
195    pub properties_remaining: Option<u32>,
196}
197
198/// What a reset-class command puts back.
199#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
200pub enum MobileMeshResetScope {
201    /// `CMD_RST`: protocol state, leaving configuration alone.
202    Protocol,
203    /// `CMD_RESTORE`: the saved snapshot, discarding unsaved changes.
204    Restore,
205    /// `CMD_REBOOT`: nothing. The device power-cycles and comes back as
206    /// itself, with everything it had persisted. A device without
207    /// `CAP_REBOOT` answers `STATUS_UNIMPLEMENTED` rather than silence,
208    /// which is the one reply this scope can produce.
209    Reboot,
210    /// `CMD_FACTORY_RESET`: everything, including the device's identity.
211    /// A device that has forgotten its identity is a different node, and
212    /// no longer reachable at the address this operation was sent to.
213    Factory,
214}
215
216/// One position of a multi-property write.
217#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
218pub struct MobileMeshPropertyWriteRecord {
219    pub property_id: u32,
220    pub value: Vec<u8>,
221}
222
223/// The writes that state a whole device configuration, ordered, for a
224/// device being configured across the mesh.
225///
226/// A phone holding a device open writes a configuration through
227/// `MobileUlcpSession::configure_device`, which owns the ordering — the
228/// PHY goes down first and comes back up last — and closes with a save.
229/// An administrator has no session to hand a record to, only the record
230/// its read produced, so it asks for the same writes here and sends them
231/// with [`MobileMeshSession::begin_management_set_many`]. The reduction is
232/// literally the same code, which is the point: the two paths cannot
233/// drift into configuring a device differently.
234///
235/// `reported` is the device as a completed read found it. Its
236/// capabilities decide which fields must be present, and the properties
237/// it would not report are left out — writing one of those fails, and a
238/// device fails the write it is on rather than the ones after it.
239#[uniffi::export]
240pub fn ulcp_device_config_writes(
241    configuration: crate::ulcp::UlcpDeviceConfigRecord,
242    reported: UlcpSyncRecord,
243) -> Result<Vec<MobileMeshPropertyWriteRecord>, MobileMeshError> {
244    let values = crate::ulcp::device_config_writes(configuration, &reported)
245        .map_err(|_| MobileMeshError::InvalidRequest)?;
246    Ok(values
247        .into_iter()
248        .map(|(property_id, value)| MobileMeshPropertyWriteRecord { property_id, value })
249        .collect())
250}
251
252/// How the MAC will address the next frame sent to a peer.
253#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
254pub enum MobileMeshRouteKind {
255    /// Nothing has been learned for this peer, so the next send falls back to
256    /// the default delivery mode. Also reported for a peer the MAC does not
257    /// have registered at all.
258    Unknown,
259    /// The peer answered without any intermediate router.
260    Direct,
261    /// An explicit source route, learned by reversing an inbound trace route.
262    Source,
263    /// Flood delivery with a learned hop budget.
264    Flood,
265}
266
267/// The route the MAC currently has cached for one peer.
268#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
269pub struct MobileMeshRouteRecord {
270    pub kind: MobileMeshRouteKind,
271    /// Router hints in source-to-destination order. Populated for `Source`
272    /// routes only; the two endpoints are not included.
273    pub hints: Vec<Vec<u8>>,
274    /// Hop budget carried by a `Flood` route.
275    pub flood_hops: Option<u8>,
276    /// Two-octet region codes learned with a `Flood` route.
277    pub flood_regions: Vec<Vec<u8>>,
278}
279
280impl MobileMeshRouteRecord {
281    fn unknown() -> Self {
282        Self {
283            kind: MobileMeshRouteKind::Unknown,
284            hints: Vec::new(),
285            flood_hops: None,
286            flood_regions: Vec::new(),
287        }
288    }
289}
290
291impl From<Option<umsh_mac::CachedRoute>> for MobileMeshRouteRecord {
292    fn from(route: Option<umsh_mac::CachedRoute>) -> Self {
293        match route {
294            None => Self::unknown(),
295            Some(umsh_mac::CachedRoute::Direct) => Self {
296                kind: MobileMeshRouteKind::Direct,
297                ..Self::unknown()
298            },
299            Some(umsh_mac::CachedRoute::Source(hops)) => Self {
300                kind: MobileMeshRouteKind::Source,
301                hints: hops.iter().map(|hop| hop.0.to_vec()).collect(),
302                ..Self::unknown()
303            },
304            Some(umsh_mac::CachedRoute::Flood { hops, regions }) => Self {
305                kind: MobileMeshRouteKind::Flood,
306                flood_hops: Some(hops),
307                flood_regions: regions.iter().map(|region| region.to_vec()).collect(),
308                ..Self::unknown()
309            },
310        }
311    }
312}
313
314/// A node-identity bundle received over the mesh, either as a broadcast
315/// advertisement or as the reply to an Identity Request.
316///
317/// Only frames whose sender the MAC could name are surfaced. How the claims
318/// may be trusted depends on how they arrived, which is what
319/// `source_authenticated` reports.
320#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
321pub struct MobileMeshAdvertisementRecord {
322    /// Canonical Base58 address of the claimed sender.
323    pub peer_address: String,
324    /// Raw node-identity payload bytes (without the payload-type byte),
325    /// decodable with `decode_node_identity`.
326    pub payload: Vec<u8>,
327    /// Whether the MAC authenticated the sender of the frame that carried
328    /// this bundle.
329    ///
330    /// A unicast Identity Request reply is authenticated by its MIC, so it
331    /// carries no detached signature and decodes as `Unsigned` — it is
332    /// nonetheless trustworthy, and the platform must accept it. A broadcast
333    /// advertisement has no MIC, so it is `false` and the platform must
334    /// require a `Valid` embedded signature before trusting any claim.
335    pub source_authenticated: bool,
336}
337
338/// One repeater a repeater told this phone about, from a Peer Repeaters
339/// Response.
340///
341/// Everything past the hint is optional because the answering node reports
342/// only what it has: an identity supplies the name, position, and regions; a
343/// reception supplies the signal; neither supplies the other. A hop that has
344/// only been heard is named by a two-byte router hint, which is all a trace
345/// reveals about it.
346#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
347pub struct MobileMeshPeerRepeaterRecord {
348    /// Three bytes when an identity named the peer, two when only a
349    /// reception did.
350    pub hint: Vec<u8>,
351    pub name: Option<String>,
352    pub rssi_dbm: Option<i16>,
353    /// Signal-to-noise ratio in quarter-decibel steps, as the wire carries
354    /// it.
355    pub snr_quarter_db: Option<i16>,
356    /// Minutes since the answering node last heard from this peer.
357    pub last_heard_minutes: Option<u16>,
358    /// The peer's position as a raw location cell, decodable with the
359    /// location helpers.
360    pub location: Option<Vec<u8>>,
361    /// The 2-octet flood-forwarding codes the peer advertised.
362    pub region_codes: Vec<Vec<u8>>,
363}
364
365/// The position this phone is willing to put in its identity.
366///
367/// Precision is the disclosure decision: the wire format carries a cell,
368/// not a point, and the coordinate is reduced to that cell before it goes
369/// anywhere. The platform hands over its best reading and the chosen cell
370/// size; the truncation happens here, on this side of every send.
371#[derive(Clone, Copy, Debug, PartialEq, uniffi::Record)]
372pub struct MobileMeshSharedLocationRecord {
373    pub latitude_degrees: f64,
374    pub longitude_degrees: f64,
375    /// Cell-code precision in bytes, 1–7. `ulcp_location_cell_meters`
376    /// names the cell size each buys.
377    pub precision_bytes: u8,
378}
379
380/// Evidence that a peer was on the air, emitted for every accepted frame
381/// regardless of what it carried.
382///
383/// A beacon is the case this exists for: it has no payload, so it produces no
384/// advertisement, no message, and no ping reply, yet it is the cheapest
385/// possible proof that a node is still reachable. Presence is not a claim
386/// about content, so nothing here needs to be authenticated to be useful —
387/// it says only that a frame naming this sender was accepted.
388#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
389pub struct MobileMeshPeerHeardRecord {
390    /// Canonical Base58 address of the sender, when the frame named a full
391    /// public key or the MAC could resolve one. `None` for a hint-only
392    /// source, which the platform may still resolve against its own peer
393    /// list — see `node_hint`.
394    pub peer_address: Option<String>,
395    /// The 3-byte source node hint, when the frame carried one. Hints are
396    /// ambiguous by design: a platform matching one against saved peers must
397    /// treat a multi-way match as no match at all.
398    pub node_hint: Option<Vec<u8>>,
399    /// Whether the MAC authenticated this frame's sender. A beacon is an
400    /// unauthenticated broadcast, so this is usually `false`; it is reported
401    /// so the platform can tell "a frame claiming to be from X" from "a frame
402    /// proven to be from X".
403    pub source_authenticated: bool,
404}
405
406/// Platform-side listener invoked when `poll_update` has new data waiting.
407///
408/// Called on the worker thread; implementations must only schedule a drain
409/// on their own executor and return. Notifications are coalesced: at most
410/// one call fires per pending-to-drained cycle, so a burst of protocol
411/// activity costs one crossing, and the platform needs no polling cadence.
412#[uniffi::export(with_foreign)]
413pub trait MobileMeshWakeListener: Send + Sync {
414    fn on_update_pending(&self);
415}
416
417/// Coalescing wake flag shared between the worker's producer channels and
418/// `poll_update`. `notify` fires the listener only on the false-to-true
419/// transition; `drained` re-arms it.
420struct WakeSignal {
421    pending: AtomicBool,
422    listener: Mutex<Option<Arc<dyn MobileMeshWakeListener>>>,
423}
424
425impl WakeSignal {
426    fn new() -> Self {
427        Self {
428            pending: AtomicBool::new(false),
429            listener: Mutex::new(None),
430        }
431    }
432
433    fn notify(&self) {
434        if self.pending.swap(true, Ordering::AcqRel) {
435            return;
436        }
437        let listener = self
438            .listener
439            .lock()
440            .ok()
441            .and_then(|slot| slot.as_ref().cloned());
442        if let Some(listener) = listener {
443            listener.on_update_pending();
444        }
445    }
446
447    fn drained(&self) {
448        self.pending.store(false, Ordering::Release);
449    }
450
451    fn set_listener(&self, listener: Option<Arc<dyn MobileMeshWakeListener>>) {
452        let already_pending = {
453            let Ok(mut slot) = self.listener.lock() else {
454                return;
455            };
456            *slot = listener.clone();
457            self.pending.load(Ordering::Acquire)
458        };
459        // Data enqueued before registration must not wait for the next
460        // protocol event to surface.
461        if already_pending && let Some(listener) = listener {
462            listener.on_update_pending();
463        }
464    }
465}
466
467/// A producer channel endpoint that arms the wake signal on every enqueue.
468struct NotifyingSender<T> {
469    tx: std_mpsc::Sender<T>,
470    wake: Arc<WakeSignal>,
471}
472
473impl<T> Clone for NotifyingSender<T> {
474    fn clone(&self) -> Self {
475        Self {
476            tx: self.tx.clone(),
477            wake: self.wake.clone(),
478        }
479    }
480}
481
482impl<T> NotifyingSender<T> {
483    fn send(&self, value: T) -> Result<(), std_mpsc::SendError<T>> {
484        self.tx.send(value)?;
485        self.wake.notify();
486        Ok(())
487    }
488}
489
490#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
491pub struct MobileMeshSessionUpdateRecord {
492    /// Complete raw UMSH frames ready for the ULCP PHY transport. Each
493    /// frame must be completed after the device reports the physical radio
494    /// result; queue acceptance is not transmit completion.
495    pub outbound_frames: Vec<MobileMeshOutboundFrameRecord>,
496    pub ping_events: Vec<MobileMeshPingEventRecord>,
497    /// Reports from operations started with `begin_management_*` or
498    /// `begin_remote_sync`, in the order they were produced.
499    pub management_events: Vec<MobileMeshManagementEventRecord>,
500    pub advertisement_events: Vec<MobileMeshAdvertisementRecord>,
501    pub peer_heard_events: Vec<MobileMeshPeerHeardRecord>,
502    /// Chat effects remain in the facade until Swift durably applies them and
503    /// acknowledges this batch. Repeated polls may return the same batch.
504    pub chat_batch_id: Option<u64>,
505    pub chat_mutations: Vec<MobileChatMutationRecord>,
506    pub chat_deliveries: Vec<MobileChatDeliveryRecord>,
507    pub chat_archive_lookups: Vec<MobileChatArchiveLookupRecord>,
508    /// Channel members whose claimed hint has resolved to a full address. The
509    /// platform should upgrade rows it stored anonymously under that hint.
510    pub chat_sender_resolutions: Vec<MobileChatSenderResolutionRecord>,
511    pub chat_diagnostics: Vec<String>,
512}
513
514#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
515pub struct MobileMeshOutboundFrameRecord {
516    pub id: u64,
517    pub data: Vec<u8>,
518    /// `TX_FLAG_NOCCA`: the device should transmit this frame without the
519    /// pre-transmit channel-activity check. Set for immediate MAC acks, which
520    /// own the channel-access window the moment the received frame ends; clear
521    /// for originated and forwarded traffic, which must listen before talking.
522    pub nocca: bool,
523}
524
525#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
526pub struct MobileMeshRxRecord {
527    pub data: Vec<u8>,
528    pub rssi_dbm: Option<i16>,
529    pub lqi: Option<u8>,
530    pub snr_cb: Option<i16>,
531}
532
533enum WorkerCommand {
534    RegisterPeers {
535        peers: Vec<PublicKey>,
536        response: oneshot::Sender<Result<(), MobileMeshError>>,
537    },
538    RemovePeers {
539        peers: Vec<PublicKey>,
540        response: oneshot::Sender<Result<(), MobileMeshError>>,
541    },
542    RegisterChannels {
543        keys: Vec<ChannelKey>,
544        response: oneshot::Sender<Result<(), MobileMeshError>>,
545    },
546    RemoveChannels {
547        keys: Vec<ChannelKey>,
548        response: oneshot::Sender<Result<(), MobileMeshError>>,
549    },
550    Ping {
551        operation_id: u64,
552        peer: PublicKey,
553        timeout_ms: u64,
554    },
555    Manage {
556        operation_id: u64,
557        peer: PublicKey,
558        request: ManagementRequest,
559    },
560    RestoreChat {
561        checkpoints: Vec<MobileChatCheckpointRecord>,
562        response: oneshot::Sender<()>,
563    },
564    ComposeChat {
565        conversation_address: String,
566        client_token: u32,
567        request: ChatComposeRequest,
568        response: oneshot::Sender<Result<MobileChatComposeBatchRecord, MobileMeshError>>,
569    },
570    CommitChatBatch {
571        batch_id: u64,
572        response: oneshot::Sender<Result<(), MobileMeshError>>,
573    },
574    RejectChatBatch {
575        batch_id: u64,
576        checkpoints: Vec<MobileChatCheckpointRecord>,
577        response: oneshot::Sender<Result<(), MobileMeshError>>,
578    },
579    ChatArchiveResult {
580        request_id: u32,
581        kind: MobileChatArchiveResultKind,
582        payload: Vec<u8>,
583    },
584    Advertise {
585        name: Option<String>,
586        timestamp: Option<u32>,
587        /// Whether this is the phone's own schedule speaking rather than
588        /// someone tapping a button. A scheduled advertisement reaches
589        /// only the neighbours that can hear the phone directly.
590        scheduled: bool,
591        response: oneshot::Sender<Result<(), MobileMeshError>>,
592    },
593    Beacon {
594        response: oneshot::Sender<Result<(), MobileMeshError>>,
595    },
596    SignIdentityBundle {
597        name: Option<String>,
598        timestamp: Option<u32>,
599        response: oneshot::Sender<Result<Vec<u8>, MobileMeshError>>,
600    },
601    RequestIdentity {
602        peer: PublicKey,
603        response: oneshot::Sender<Result<(), MobileMeshError>>,
604    },
605    DiscoverIdentities {
606        role_code: Option<u8>,
607        capability_bits: Option<u8>,
608        /// Leading bytes of the one node's hint that should answer; two of
609        /// them is a router hint.
610        node_hint: Option<Vec<u8>>,
611        /// Routers to steer the request through, in send order; empty for a
612        /// zero-hop ask of this node's own neighbors.
613        source_route: Vec<Vec<u8>>,
614        response: oneshot::Sender<Result<(), MobileMeshError>>,
615    },
616    RequestIdentityByHint {
617        conversation_address: String,
618        hint: NodeHint,
619        response: oneshot::Sender<Result<(), MobileMeshError>>,
620    },
621    PeerRepeaters {
622        peer: PublicKey,
623        response: oneshot::Sender<Result<Vec<MobileMeshPeerRepeaterRecord>, MobileMeshError>>,
624    },
625    SetDiscoverable {
626        enabled: bool,
627        name: Option<String>,
628        response: oneshot::Sender<()>,
629    },
630    /// Already reduced to the disclosed cell; `None` stops sharing.
631    SetAdvertisedLocation {
632        location: Option<NodeLocation>,
633        response: oneshot::Sender<()>,
634    },
635    SetChatDisplayName {
636        name: String,
637        response: oneshot::Sender<()>,
638    },
639    PeerRoute {
640        peer: PublicKey,
641        response: oneshot::Sender<MobileMeshRouteRecord>,
642    },
643    ClearPeerRoute {
644        peer: PublicKey,
645        response: oneshot::Sender<bool>,
646    },
647    FailOutboundTransmissions,
648    Receive(MobileMeshRxRecord),
649    Shutdown,
650}
651
652/// What kind of answer a management request expects, which is what makes
653/// the reply frame readable.
654#[derive(Clone, Debug)]
655enum ReplyShape {
656    /// A `CMD_PROP_IS` for this property, or a status standing in for it.
657    Property(u32),
658    /// A `CMD_PROP_ARE` covering these properties, in order.
659    Entries(Vec<u32>),
660    /// A bare `PROP_LAST_STATUS`, which is what a save reports.
661    Status,
662    /// Nothing at all: a reset-class command is completed by the
663    /// acknowledgment.
664    Acknowledgment,
665}
666
667/// What the phone was asked to do to a device over the mesh.
668enum ManagementRequest {
669    /// One request frame, already encoded, and the shape of its answer.
670    One { frame: Vec<u8>, shape: ReplyShape },
671    /// Read a named set of properties, in as many exchanges as it takes.
672    Fetch {
673        property_ids: Vec<u32>,
674        multi_hint: bool,
675    },
676}
677
678enum ChatComposeRequest {
679    Text {
680        body: String,
681    },
682    Edit {
683        original: MobileChatOriginalRef,
684        body: String,
685    },
686    Delete {
687        original: MobileChatOriginalRef,
688    },
689    Reaction {
690        target: MobileChatRegardingRef,
691        body: String,
692    },
693}
694
695struct InboundFrame {
696    record: MobileMeshRxRecord,
697}
698
699/// Who a received text frame came from, and over what.
700enum InboundTextSource {
701    /// Authenticated unicast from a known peer.
702    Direct { peer: PublicKey },
703    /// Multicast to a channel. The sender claims a hint; the full key is
704    /// present only when they addressed the frame with it.
705    ChannelGroup {
706        channel: ChannelTag,
707        hint: NodeHint,
708        full_key: Option<PublicKey>,
709    },
710    /// Blind unicast to us over a channel key, from an addressable peer.
711    ChannelDirect {
712        channel: ChannelTag,
713        peer: PublicKey,
714    },
715}
716
717struct InboundText {
718    source: InboundTextSource,
719    payload: Vec<u8>,
720    received_at_ms: Option<u64>,
721    rx: MobileChatRxMetadataRecord,
722}
723
724struct InFlightChatTransmission {
725    transmission_id: u32,
726    /// The peer whose first-contact pipeline this send gates on. Channel
727    /// sends have no such peer: multicast is unaddressed and blind unicast
728    /// carries no ACK to confirm with.
729    gate_peer: Option<PublicKey>,
730    ticket: SendProgressTicket,
731    sent_reported: bool,
732    /// No acknowledgement will ever arrive, so transmission is the terminal
733    /// success state rather than a step toward one.
734    non_ack: bool,
735    /// When this entry entered the window, and whether it has already been
736    /// reported as overdue. A frame that never resolves holds a slot in a
737    /// window of eight; eight of them stop chat entirely, and from the outside
738    /// that looks like messages hanging on "Sending" for no reason.
739    queued_at_ms: u64,
740    stall_reported: bool,
741}
742
743/// How long an in-flight transmission may go unresolved before it is called
744/// out. Longer than any ordinary ACK wait, so this fires on trouble rather
745/// than on a slow link.
746const CHAT_TRANSMISSION_STALL_MS: u64 = 60_000;
747
748#[derive(Clone)]
749enum MobileChatWorkerEvent {
750    Mutation(MobileChatMutationRecord),
751    SenderResolution(MobileChatSenderResolutionRecord),
752    Delivery(MobileChatDeliveryRecord),
753    ArchiveLookup(MobileChatArchiveLookupRecord),
754    Diagnostic(String),
755}
756
757struct PendingChatEventBatch {
758    id: u64,
759    events: Vec<MobileChatWorkerEvent>,
760}
761
762#[derive(Debug)]
763enum BridgeRadioError {
764    Closed,
765    FrameTooLarge,
766}
767
768struct BridgeTransmitCompletions {
769    next_id: AtomicU64,
770    failure_generation: AtomicU64,
771    /// A link-wide failure was declared and its `FailOutboundTransmissions`
772    /// command has not yet been processed by the worker. While set, no new
773    /// transmission may reach the platform: the MAC's in-progress drain loop
774    /// would otherwise keep dispatching the frames queued behind the one the
775    /// failure caught mid-flight, because each later `transmit` call samples
776    /// the generation only after the bump. The worker clears the flag when
777    /// it processes the queued command and cancels the affected tickets.
778    poisoned: AtomicBool,
779    pending: Mutex<BTreeMap<u64, oneshot::Sender<bool>>>,
780}
781
782impl BridgeTransmitCompletions {
783    fn new() -> Self {
784        Self {
785            next_id: AtomicU64::new(1),
786            failure_generation: AtomicU64::new(0),
787            poisoned: AtomicBool::new(false),
788            pending: Mutex::new(BTreeMap::new()),
789        }
790    }
791
792    fn generation(&self) -> u64 {
793        self.failure_generation.load(Ordering::SeqCst)
794    }
795
796    fn allocate(
797        &self,
798        generation: u64,
799        completion: oneshot::Sender<bool>,
800    ) -> Result<Option<u64>, BridgeRadioError> {
801        let id = self.next_id.fetch_add(1, Ordering::Relaxed).max(1);
802        let mut pending = self.pending.lock().map_err(|_| BridgeRadioError::Closed)?;
803        if self.poisoned.load(Ordering::SeqCst)
804            || generation != self.failure_generation.load(Ordering::SeqCst)
805        {
806            return Ok(None);
807        }
808        pending.insert(id, completion);
809        Ok(Some(id))
810    }
811
812    /// Declare a link-wide failure: refuse new platform dispatches until the
813    /// worker processes the corresponding cancellation command.
814    fn poison(&self) {
815        self.poisoned.store(true, Ordering::SeqCst);
816        self.failure_generation.fetch_add(1, Ordering::SeqCst);
817    }
818
819    fn clear_poison(&self) {
820        self.poisoned.store(false, Ordering::SeqCst);
821    }
822
823    fn complete(&self, id: u64, transmitted: bool) -> bool {
824        self.pending
825            .lock()
826            .ok()
827            .and_then(|mut pending| pending.remove(&id))
828            .is_some_and(|completion| completion.send(transmitted).is_ok())
829    }
830
831    fn fail_all(&self) {
832        self.failure_generation.fetch_add(1, Ordering::SeqCst);
833        let completions = self
834            .pending
835            .lock()
836            .map(|mut pending| core::mem::take(&mut *pending))
837            .unwrap_or_default();
838        for completion in completions.into_values() {
839            let _ = completion.send(false);
840        }
841    }
842}
843
844struct BridgeRadio {
845    inbound: mpsc::UnboundedReceiver<InboundFrame>,
846    outbound: NotifyingSender<MobileMeshOutboundFrameRecord>,
847    completions: Arc<BridgeTransmitCompletions>,
848}
849
850impl Radio for BridgeRadio {
851    type Error = BridgeRadioError;
852
853    async fn transmit(
854        &mut self,
855        data: &[u8],
856        options: TxOptions,
857    ) -> Result<(), TxError<Self::Error>> {
858        if data.len() > MAX_FRAME_SIZE {
859            return Err(TxError::Io(BridgeRadioError::FrameTooLarge));
860        }
861        // The MAC skips CAD only for immediate acks (channel-access.md
862        // § Immediate ACK Transmission); every other policy asks the
863        // device to listen before talking.
864        let nocca = matches!(options.cad, umsh_hal::CadPolicy::Skip);
865        let (completion_tx, completion_rx) = oneshot::channel();
866        let generation = self.completions.generation();
867        let Some(id) = self
868            .completions
869            .allocate(generation, completion_tx)
870            .map_err(TxError::Io)?
871        else {
872            // A link-wide failure raced this send before it reached the
873            // platform. Its queued cancellation owns the ticket outcome.
874            return Ok(());
875        };
876        if self
877            .outbound
878            .send(MobileMeshOutboundFrameRecord {
879                id,
880                data: data.to_vec(),
881                nocca,
882            })
883            .is_err()
884        {
885            let _ = self.completions.complete(id, false);
886            return Err(TxError::Io(BridgeRadioError::Closed));
887        }
888
889        // Awaiting here is deliberate: Radio::transmit completes only after
890        // the frame has actually left the radio PHY. Returning at
891        // bridge-queue acceptance starts MAC ACK timers too early and causes
892        // fragmented sends to retransmit frames that are still waiting in
893        // the device queue. This is an async wait, not a thread block, so
894        // the worker keeps servicing commands and timers while the frame is
895        // in flight; the MAC itself stays serialized behind its own borrow.
896        match completion_rx.await {
897            Ok(true) => Ok(()),
898            // The public completion API poisons the bridge and queues
899            // FailOutboundTransmissions before releasing this wait. Return
900            // success here solely to keep an ordinary rejected frame from
901            // terminating the long-lived MAC driver; the queued command
902            // cancels its ACK ticket immediately.
903            Ok(false) => Ok(()),
904            Err(_) => Err(TxError::Io(BridgeRadioError::Closed)),
905        }
906    }
907
908    fn poll_receive(
909        &mut self,
910        cx: &mut Context<'_>,
911        buf: &mut [u8],
912    ) -> Poll<Result<RxInfo, Self::Error>> {
913        match self.inbound.poll_recv(cx) {
914            Poll::Ready(Some(frame)) => {
915                if frame.record.data.len() > buf.len() {
916                    return Poll::Ready(Err(BridgeRadioError::FrameTooLarge));
917                }
918                let len = frame.record.data.len();
919                buf[..len].copy_from_slice(&frame.record.data);
920                Poll::Ready(Ok(RxInfo {
921                    len,
922                    rssi: frame.record.rssi_dbm.unwrap_or(0),
923                    snr: Snr::from_centibels(frame.record.snr_cb.unwrap_or(0)),
924                    lqi: frame.record.lqi.and_then(core::num::NonZeroU8::new),
925                    origin: RxOrigin::Air,
926                }))
927            }
928            Poll::Ready(None) => Poll::Ready(Err(BridgeRadioError::Closed)),
929            Poll::Pending => Poll::Pending,
930        }
931    }
932
933    fn max_frame_size(&self) -> usize {
934        MAX_FRAME_SIZE
935    }
936    fn t_frame_ms(&self) -> u32 {
937        DEFAULT_FRAME_TIME_MS
938    }
939}
940
941#[derive(Clone)]
942struct SharedCounterStore(Arc<MobileCounterStore>);
943
944impl CounterStore for SharedCounterStore {
945    type Error = crate::CounterStoreError;
946
947    async fn load(&self, context: &[u8]) -> Result<u32, Self::Error> {
948        self.0.load_boundary(context.to_vec())
949    }
950
951    async fn store(&self, context: &[u8], value: u32) -> Result<(), Self::Error> {
952        self.0.commit_boundary(context.to_vec(), value)
953    }
954
955    async fn flush(&self) -> Result<(), Self::Error> {
956        CounterStore::flush(self.0.as_ref()).await
957    }
958}
959
960#[derive(Clone, Default)]
961struct MemoryKeyValueStore(Arc<Mutex<BTreeMap<Vec<u8>, Vec<u8>>>>);
962
963impl KeyValueStore for MemoryKeyValueStore {
964    type Error = MobileMeshError;
965
966    async fn load(&self, key: &[u8], out: &mut [u8]) -> Result<Option<usize>, Self::Error> {
967        let values = self
968            .0
969            .lock()
970            .map_err(|_| MobileMeshError::SessionUnavailable)?;
971        let Some(value) = values.get(key) else {
972            return Ok(None);
973        };
974        if value.len() > out.len() {
975            return Err(MobileMeshError::SessionUnavailable);
976        }
977        out[..value.len()].copy_from_slice(value);
978        Ok(Some(value.len()))
979    }
980
981    async fn store(&self, key: &[u8], value: &[u8]) -> Result<(), Self::Error> {
982        self.0
983            .lock()
984            .map_err(|_| MobileMeshError::SessionUnavailable)?
985            .insert(key.to_vec(), value.to_vec());
986        Ok(())
987    }
988
989    async fn delete(&self, key: &[u8]) -> Result<(), Self::Error> {
990        self.0
991            .lock()
992            .map_err(|_| MobileMeshError::SessionUnavailable)?
993            .remove(key);
994        Ok(())
995    }
996}
997
998/// MAC clock backed by tokio's time source. Using `tokio::time::Instant`
999/// (rather than `std::time::Instant`) means a runtime started with paused
1000/// time drives this clock too, so every MAC deadline can be fast-forwarded
1001/// deterministically in tests.
1002#[derive(Clone)]
1003struct MobileClock {
1004    origin: tokio::time::Instant,
1005    sleep: Rc<RefCell<Option<Pin<Box<tokio::time::Sleep>>>>>,
1006}
1007
1008impl MobileClock {
1009    fn new() -> Self {
1010        Self {
1011            origin: tokio::time::Instant::now(),
1012            sleep: Rc::new(RefCell::new(None)),
1013        }
1014    }
1015}
1016
1017impl Clock for MobileClock {
1018    fn now_ms(&self) -> u64 {
1019        self.origin.elapsed().as_millis() as u64
1020    }
1021
1022    fn poll_delay_until(&self, cx: &mut Context<'_>, deadline_ms: u64) -> Poll<()> {
1023        let deadline = self.origin + Duration::from_millis(deadline_ms);
1024        if tokio::time::Instant::now() >= deadline {
1025            return Poll::Ready(());
1026        }
1027        let mut slot = self.sleep.borrow_mut();
1028        let sleep = slot.get_or_insert_with(|| Box::pin(tokio::time::sleep_until(deadline)));
1029        sleep.as_mut().reset(deadline);
1030        sleep.as_mut().poll(cx)
1031    }
1032}
1033
1034#[derive(Clone, Copy, Default)]
1035struct MobileDelay;
1036
1037impl DelayNs for MobileDelay {
1038    async fn delay_ns(&mut self, ns: u32) {
1039        tokio::time::sleep(Duration::from_nanos(u64::from(ns))).await;
1040    }
1041}
1042
1043struct MobilePlatform(PhantomData<()>);
1044
1045impl umsh_mac::Platform for MobilePlatform {
1046    type Identity = SoftwareIdentity;
1047    type Aes = SoftwareAes;
1048    type Sha = SoftwareSha256;
1049    type Radio = BridgeRadio;
1050    type Delay = MobileDelay;
1051    type Clock = MobileClock;
1052    type Rng = rand::rngs::ThreadRng;
1053    type CounterStore = SharedCounterStore;
1054    type KeyValueStore = MemoryKeyValueStore;
1055}
1056
1057/// Peer capacity of the phone's in-memory MAC. The embedded default (16) is
1058/// sized for microcontroller RAM; the app registers a peer per conversation
1059/// plus every checkpointed stream, which can plausibly exceed it, and phone
1060/// RAM is not the constraint.
1061const MOBILE_MAC_PEERS: usize = 64;
1062
1063/// Channel capacity of the phone's in-memory MAC. The embedded default of 8
1064/// is sized for microcontroller RAM and already spends two slots on the
1065/// default `public` and `EMERGENCY` channels; per-channel replay state is a
1066/// few hundred bytes, which phone RAM does not need to ration.
1067const MOBILE_MAC_CHANNELS: usize = 32;
1068
1069type MobileMac =
1070    Mac<MobilePlatform, { umsh_mac::DEFAULT_IDENTITIES }, MOBILE_MAC_PEERS, MOBILE_MAC_CHANNELS>;
1071const MOBILE_CHAT_TRANSMIT_WINDOW: usize = 8;
1072
1073/// Long-lived Rust protocol engine used by the mobile app.
1074///
1075/// `ping` is the only ping operation exposed to Swift. The existing Rust node
1076/// layer owns its nonce, authenticated echo request, counter reservation,
1077/// response matching, and timeout.
1078#[derive(uniffi::Object)]
1079pub struct MobileMeshSession {
1080    /// This phone's node public key. Held here rather than asked of the
1081    /// worker: it never changes for the life of a session, and it is what
1082    /// a device has to list before this phone may manage it.
1083    local_key: PublicKey,
1084    commands: mpsc::UnboundedSender<WorkerCommand>,
1085    outbound: Mutex<std_mpsc::Receiver<MobileMeshOutboundFrameRecord>>,
1086    transmit_completions: Arc<BridgeTransmitCompletions>,
1087    events: Mutex<std_mpsc::Receiver<MobileMeshPingEventRecord>>,
1088    management: Mutex<std_mpsc::Receiver<MobileMeshManagementEventRecord>>,
1089    advertisements: Mutex<std_mpsc::Receiver<MobileMeshAdvertisementRecord>>,
1090    peer_heard: Mutex<std_mpsc::Receiver<MobileMeshPeerHeardRecord>>,
1091    chat_events: Mutex<std_mpsc::Receiver<MobileChatWorkerEvent>>,
1092    pending_chat_events: Mutex<Option<PendingChatEventBatch>>,
1093    next_chat_batch_id: Mutex<u64>,
1094    next_operation_id: Mutex<u64>,
1095    wake: Arc<WakeSignal>,
1096}
1097
1098#[uniffi::export]
1099impl MobileMeshSession {
1100    #[uniffi::constructor]
1101    pub async fn new(
1102        identity: Arc<MobileIdentity>,
1103        counter_store: Arc<MobileCounterStore>,
1104    ) -> Result<Arc<Self>, MobileMeshError> {
1105        Self::build(identity, counter_store, false).await
1106    }
1107
1108    pub fn ping(&self, peer_address: String, timeout_ms: u64) -> Result<u64, MobileMeshError> {
1109        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
1110        let operation_id = self.next_operation_id()?;
1111        self.commands
1112            .send(WorkerCommand::Ping {
1113                operation_id,
1114                peer,
1115                timeout_ms,
1116            })
1117            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1118        Ok(operation_id)
1119    }
1120
1121    /// This phone's own node public key, which is what a device lists in
1122    /// `PROP_DEV_ADMINS` to let this phone manage it over the mesh.
1123    ///
1124    /// Handing this to a radio the phone is attached to —
1125    /// `MobileUlcpSession::insert_device_admin` — is the whole of making
1126    /// this phone an administrator of that radio. Nothing else is
1127    /// exchanged: the session both ends derive comes from their two
1128    /// identities.
1129    pub fn node_public_key(&self) -> Vec<u8> {
1130        self.local_key.0.to_vec()
1131    }
1132
1133    /// Read one property from a device across the mesh.
1134    ///
1135    /// Every `begin_management_*` call returns immediately with an
1136    /// operation identifier, and reports through `poll_update` — the same
1137    /// shape as `ping`, because it is the same kind of thing: a
1138    /// round-trip over a network that promises nothing. One operation runs
1139    /// at a time; starting another while one is outstanding fails it.
1140    pub fn begin_management_get(
1141        &self,
1142        peer_address: String,
1143        property_id: u32,
1144    ) -> Result<u64, MobileMeshError> {
1145        let frame = encode_management(|buf| frame::prop_get(buf, 0, property_id))?;
1146        self.begin_management(
1147            peer_address,
1148            ManagementRequest::One {
1149                frame,
1150                shape: ReplyShape::Property(property_id),
1151            },
1152        )
1153    }
1154
1155    /// Write one property on a device across the mesh.
1156    ///
1157    /// The answer echoes what the property is now worth, which is what the
1158    /// device kept rather than what was sent. The change is live and
1159    /// unsaved; `begin_management_save` is what makes it survive a reboot.
1160    pub fn begin_management_set(
1161        &self,
1162        peer_address: String,
1163        property_id: u32,
1164        value: Vec<u8>,
1165    ) -> Result<u64, MobileMeshError> {
1166        let frame = encode_management(|buf| frame::prop_set(buf, 0, property_id, &value))?;
1167        self.begin_management(
1168            peer_address,
1169            ManagementRequest::One {
1170                frame,
1171                shape: ReplyShape::Property(property_id),
1172            },
1173        )
1174    }
1175
1176    /// Add one item to a multiple-value property on a device across the
1177    /// mesh — a peer key, an administrator key, a channel key.
1178    pub fn begin_management_insert(
1179        &self,
1180        peer_address: String,
1181        property_id: u32,
1182        item: Vec<u8>,
1183    ) -> Result<u64, MobileMeshError> {
1184        let frame = encode_management(|buf| frame::prop_insert(buf, 0, property_id, &item))?;
1185        self.begin_management(
1186            peer_address,
1187            ManagementRequest::One {
1188                frame,
1189                shape: ReplyShape::Property(property_id),
1190            },
1191        )
1192    }
1193
1194    /// Take one item out of a multiple-value property on a device across
1195    /// the mesh.
1196    pub fn begin_management_remove(
1197        &self,
1198        peer_address: String,
1199        property_id: u32,
1200        selector: Vec<u8>,
1201    ) -> Result<u64, MobileMeshError> {
1202        let frame = encode_management(|buf| frame::prop_remove(buf, 0, property_id, &selector))?;
1203        self.begin_management(
1204            peer_address,
1205            ManagementRequest::One {
1206                frame,
1207                shape: ReplyShape::Property(property_id),
1208            },
1209        )
1210    }
1211
1212    /// Let one more node manage this device over the mesh, by adding its
1213    /// public key to `PROP_DEV_ADMINS`.
1214    ///
1215    /// Named rather than left to [`Self::begin_management_insert`] for the
1216    /// same reason `MobileUlcpSession::insert_device_admin` is: this is a
1217    /// decision about who may configure a node, and a caller should not
1218    /// have to name the property — or be able to reach a different one by
1219    /// naming it wrong. The device holds it live until a save.
1220    pub fn begin_management_insert_admin(
1221        &self,
1222        peer_address: String,
1223        public_key: Vec<u8>,
1224    ) -> Result<u64, MobileMeshError> {
1225        if public_key.len() != items::PUBLIC_KEY_LEN {
1226            return Err(MobileMeshError::InvalidRequest);
1227        }
1228        self.begin_management_insert(peer_address, prop::DEV_ADMINS, public_key)
1229    }
1230
1231    /// Take a node's authority to manage this device away again.
1232    ///
1233    /// A device that removes the administrator it is answering keeps
1234    /// answering this exchange — the reply is already authorized — and
1235    /// refuses the next one.
1236    pub fn begin_management_remove_admin(
1237        &self,
1238        peer_address: String,
1239        public_key: Vec<u8>,
1240    ) -> Result<u64, MobileMeshError> {
1241        if public_key.len() != items::PUBLIC_KEY_LEN {
1242            return Err(MobileMeshError::InvalidRequest);
1243        }
1244        self.begin_management_remove(peer_address, prop::DEV_ADMINS, public_key)
1245    }
1246
1247    /// Store one more peer public key on a device's identity
1248    /// (`PROP_DEV_PEERS`), so it can hold a secure session with that node
1249    /// on its own.
1250    ///
1251    /// Named for the same reason the administrator pair is: the caller is
1252    /// deciding who a device talks to, not writing to a numbered property.
1253    /// Live until a save.
1254    pub fn begin_management_insert_peer(
1255        &self,
1256        peer_address: String,
1257        public_key: Vec<u8>,
1258    ) -> Result<u64, MobileMeshError> {
1259        if public_key.len() != items::PUBLIC_KEY_LEN {
1260            return Err(MobileMeshError::InvalidRequest);
1261        }
1262        self.begin_management_insert(peer_address, prop::DEV_PEERS, public_key)
1263    }
1264
1265    /// Drop a peer public key from a device's identity.
1266    pub fn begin_management_remove_peer(
1267        &self,
1268        peer_address: String,
1269        public_key: Vec<u8>,
1270    ) -> Result<u64, MobileMeshError> {
1271        if public_key.len() != items::PUBLIC_KEY_LEN {
1272            return Err(MobileMeshError::InvalidRequest);
1273        }
1274        self.begin_management_remove(peer_address, prop::DEV_PEERS, public_key)
1275    }
1276
1277    /// Tell a device to make itself conspicuous, or to stop
1278    /// (`PROP_ALERT`).
1279    ///
1280    /// Live state, never saved: an alert is a thing happening now, and one
1281    /// restored at boot would be a device that woke up beeping. The device
1282    /// ends it on its own deadline as well, so a search that outlasts that
1283    /// is kept alive by asking again — the same contract as the local link,
1284    /// with the round trip of the mesh in front of it.
1285    pub fn begin_management_set_alert(
1286        &self,
1287        peer_address: String,
1288        state: crate::ulcp::UlcpAlertState,
1289    ) -> Result<u64, MobileMeshError> {
1290        let value = crate::ulcp::encode_alert_state(state).map_err(|_| {
1291            // The encoding is total over the enum; a failure here would be
1292            // a bug in this crate rather than anything the caller did.
1293            MobileMeshError::InvalidRequest
1294        })?;
1295        self.begin_management_set(peer_address, prop::ALERT, value)
1296    }
1297
1298    /// Read several properties in one exchange.
1299    ///
1300    /// A device answers as many as fit and stops; the answers that arrive
1301    /// are the ones it sent, and the rest are simply absent. Requires
1302    /// `CAP_CMD_MULTI` on the device — one that lacks it refuses the whole
1303    /// request rather than answering part of it.
1304    pub fn begin_management_get_many(
1305        &self,
1306        peer_address: String,
1307        property_ids: Vec<u32>,
1308    ) -> Result<u64, MobileMeshError> {
1309        if property_ids.is_empty() {
1310            return Err(MobileMeshError::InvalidRequest);
1311        }
1312        let frame = encode_management(|buf| frame::prop_multi_get(buf, 0, &property_ids))?;
1313        self.begin_management(
1314            peer_address,
1315            ManagementRequest::One {
1316                frame,
1317                shape: ReplyShape::Entries(property_ids),
1318            },
1319        )
1320    }
1321
1322    /// Write several properties in one exchange, in order.
1323    ///
1324    /// A device applies them until the next answer would not fit and stops
1325    /// there, so a short answer means the remainder was never attempted.
1326    /// Each position echoes what that property is now worth.
1327    pub fn begin_management_set_many(
1328        &self,
1329        peer_address: String,
1330        writes: Vec<MobileMeshPropertyWriteRecord>,
1331    ) -> Result<u64, MobileMeshError> {
1332        if writes.is_empty() {
1333            return Err(MobileMeshError::InvalidRequest);
1334        }
1335        let property_ids: Vec<u32> = writes.iter().map(|write| write.property_id).collect();
1336        let entries: Vec<(u32, &[u8])> = writes
1337            .iter()
1338            .map(|write| (write.property_id, write.value.as_slice()))
1339            .collect();
1340        let frame = encode_management(|buf| frame::prop_multi_set(buf, 0, &entries))?;
1341        self.begin_management(
1342            peer_address,
1343            ManagementRequest::One {
1344                frame,
1345                shape: ReplyShape::Entries(property_ids),
1346            },
1347        )
1348    }
1349
1350    /// Persist a device's live configuration across the mesh.
1351    pub fn begin_management_save(&self, peer_address: String) -> Result<u64, MobileMeshError> {
1352        let frame = encode_management(|buf| frame::save(buf, 0))?;
1353        self.begin_management(
1354            peer_address,
1355            ManagementRequest::One {
1356                frame,
1357                shape: ReplyShape::Status,
1358            },
1359        )
1360    }
1361
1362    /// Clear a device's Bluetooth bonds across the mesh
1363    /// (`CMD_BLE_CLEAR_BONDS`): forget every paired host, the pairing
1364    /// PIN, and the pairing lockout, then open a pairing window.
1365    ///
1366    /// The command answers with a status rather than silence: it is not
1367    /// reset-class, and what it did is the whole of what it reports.
1368    /// `STATUS_UNIMPLEMENTED` is a device that does not manage its own
1369    /// bonds.
1370    ///
1371    /// Clearing bonds over the mesh does not touch this exchange — the
1372    /// administrator is addressing the device's node, not one of its
1373    /// Bluetooth hosts — so unlike the same command over Bluetooth, the
1374    /// reply arrives on a link that survives it.
1375    pub fn begin_management_ble_clear_bonds(
1376        &self,
1377        peer_address: String,
1378    ) -> Result<u64, MobileMeshError> {
1379        let frame = encode_management(|buf| frame::ble_clear_bonds(buf, 0))?;
1380        self.begin_management(
1381            peer_address,
1382            ManagementRequest::One {
1383                frame,
1384                shape: ReplyShape::Status,
1385            },
1386        )
1387    }
1388
1389    /// Reset a device across the mesh.
1390    ///
1391    /// A device answers a reset with nothing — it is busy doing what was
1392    /// asked — so the operation ends `Acknowledged` on the MAC
1393    /// acknowledgment. `Restore` on a device holding no snapshot resets
1394    /// nothing and answers like any other command, which arrives as an
1395    /// ordinary `Replied` status.
1396    pub fn begin_management_reset(
1397        &self,
1398        peer_address: String,
1399        scope: MobileMeshResetScope,
1400    ) -> Result<u64, MobileMeshError> {
1401        let frame = encode_management(|buf| match scope {
1402            MobileMeshResetScope::Protocol => frame::reset(buf, 0),
1403            MobileMeshResetScope::Restore => frame::restore(buf, 0),
1404            MobileMeshResetScope::Reboot => frame::reboot(buf, 0),
1405            MobileMeshResetScope::Factory => frame::factory_reset(buf, 0),
1406        })?;
1407        self.begin_management(
1408            peer_address,
1409            ManagementRequest::One {
1410                frame,
1411                shape: ReplyShape::Acknowledgment,
1412            },
1413        )
1414    }
1415
1416    /// Read a named set of properties across the mesh.
1417    ///
1418    /// The caller names what it wants, in as many exchanges as the
1419    /// answers need — a screenful of settings is normally one. Every
1420    /// property comes back answered, refusals included, so a caller can
1421    /// tell "the device would not say" from "nobody asked".
1422    ///
1423    /// `multi_hint` says whether to open with a batched request. A device
1424    /// that declines one is asked again a property at a time, so the hint
1425    /// costs a round trip when wrong rather than an answer. Pass what
1426    /// `CAP_CMD_MULTI` last said, or true before anything has.
1427    ///
1428    /// Every exchange is airtime over a link that may be several hops
1429    /// deep. Ask for what a screen needs and no more.
1430    pub fn begin_management_fetch(
1431        &self,
1432        peer_address: String,
1433        property_ids: Vec<u32>,
1434        multi_hint: bool,
1435    ) -> Result<u64, MobileMeshError> {
1436        self.begin_management(
1437            peer_address,
1438            ManagementRequest::Fetch {
1439                property_ids,
1440                multi_hint,
1441            },
1442        )
1443    }
1444
1445    /// Broadcast a signed node-identity advertisement describing this phone.
1446    ///
1447    /// The bundle always carries the standalone EdDSA signature because a
1448    /// broadcast frame has no MIC to authenticate it.
1449    pub async fn advertise_identity(
1450        &self,
1451        name: Option<String>,
1452        timestamp: Option<u32>,
1453    ) -> Result<(), MobileMeshError> {
1454        self.send_advertisement(name, timestamp, false).await
1455    }
1456
1457    /// The same advertisement, sent because the phone's own interval came
1458    /// round rather than because someone asked for it.
1459    ///
1460    /// Reaches only direct neighbours. A repeated statement of who this
1461    /// phone is does not need to cross the mesh every time; introducing
1462    /// it, which is what the manual send does, is the case that does.
1463    pub async fn advertise_identity_scheduled(
1464        &self,
1465        name: Option<String>,
1466        timestamp: Option<u32>,
1467    ) -> Result<(), MobileMeshError> {
1468        self.send_advertisement(name, timestamp, true).await
1469    }
1470
1471    async fn send_advertisement(
1472        &self,
1473        name: Option<String>,
1474        timestamp: Option<u32>,
1475        scheduled: bool,
1476    ) -> Result<(), MobileMeshError> {
1477        let (response, result) = oneshot::channel();
1478        self.commands
1479            .send(WorkerCommand::Advertise {
1480                name,
1481                timestamp,
1482                scheduled,
1483                response,
1484            })
1485            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1486        result
1487            .await
1488            .map_err(|_| MobileMeshError::SessionUnavailable)?
1489    }
1490
1491    /// Broadcast an empty beacon: no payload, so what it publishes is the
1492    /// path back to this phone rather than who this phone is. Costs a
1493    /// fraction of an advertisement.
1494    pub async fn send_beacon(&self) -> Result<(), MobileMeshError> {
1495        let (response, result) = oneshot::channel();
1496        self.commands
1497            .send(WorkerCommand::Beacon { response })
1498            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1499        result
1500            .await
1501            .map_err(|_| MobileMeshError::SessionUnavailable)?
1502    }
1503
1504    /// Solicit a specific peer's current node identity by sending a targeted
1505    /// MAC Identity Request (command 1). This resolves once the request has
1506    /// been handed to the transport; the peer's identity response arrives
1507    /// later as a `NodeIdentity` advertisement on the normal receive path
1508    /// (surfaced through `poll_update`'s advertisement events).
1509    pub async fn request_identity(&self, peer_address: String) -> Result<(), MobileMeshError> {
1510        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
1511        let (response, result) = oneshot::channel();
1512        self.commands
1513            .send(WorkerCommand::RequestIdentity { peer, response })
1514            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1515        result
1516            .await
1517            .map_err(|_| MobileMeshError::SessionUnavailable)?
1518    }
1519
1520    /// Solicit identities with one broadcast MAC Identity Request, either
1521    /// from this node's own neighbors or from a remote vantage point.
1522    ///
1523    /// With an empty `source_route` the request goes out as a direct
1524    /// broadcast with no flood budget, so repeaters never carry it — the
1525    /// blast radius is exactly the nodes in radio range. Given a route, the
1526    /// request is steered along it instead: each repeater consumes its hint,
1527    /// so the request arrives with an empty Route option in the neighborhood
1528    /// the route ends at, and the nodes *there* are the ones that answer. A
1529    /// steered request also carries a trace route, which is what gives the
1530    /// answering strangers a path back — without it their replies would have
1531    /// no route and no flood budget, and would die on their own transmitter.
1532    ///
1533    /// Either way it carries this phone's full source address, so a matching
1534    /// node can reply with a targeted unicast without any prior contact;
1535    /// replies arrive as ordinary `NodeIdentity` advertisements on the
1536    /// receive path. `role_code` and `capability_bits` narrow which nodes
1537    /// respond (AND-combined when both are given); `None` for all three
1538    /// filters asks every node the request reaches.
1539    ///
1540    /// `node_hint` narrows the ask to one node by the leading bytes of its
1541    /// node hint. Two bytes is a router hint, which is all a route reveals
1542    /// about the hops it crosses: pair it with a route ending at the hop
1543    /// *before* the one in question, since a repeater consumes its own hint
1544    /// only when forwarding and drops a request that still names it.
1545    ///
1546    /// Each entry of `source_route` is one 2-byte router hint in send order.
1547    pub async fn discover_identities(
1548        &self,
1549        role_code: Option<u8>,
1550        capability_bits: Option<u8>,
1551        node_hint: Option<Vec<u8>>,
1552        source_route: Vec<Vec<u8>>,
1553    ) -> Result<(), MobileMeshError> {
1554        let (response, result) = oneshot::channel();
1555        self.commands
1556            .send(WorkerCommand::DiscoverIdentities {
1557                role_code,
1558                capability_bits,
1559                node_hint,
1560                source_route,
1561                response,
1562            })
1563            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1564        result
1565            .await
1566            .map_err(|_| MobileMeshError::SessionUnavailable)?
1567    }
1568
1569    /// Ask a channel member who is known only by their claimed hint to send
1570    /// their identity.
1571    ///
1572    /// A group message carries a 3-byte hint and nothing else, so there is no
1573    /// address to unicast a request to. This goes out over the channel itself,
1574    /// filtered to that hint, and only the member it names answers — with a
1575    /// targeted unicast, since the request carries this phone's full address.
1576    ///
1577    /// The request is routed by what that member's own frames have shown:
1578    /// their observed trace route if one is known, otherwise a flood budget
1579    /// bounded by the hops their last message took rather than a default.
1580    pub async fn request_identity_by_hint(
1581        &self,
1582        conversation_address: String,
1583        hint: Vec<u8>,
1584    ) -> Result<(), MobileMeshError> {
1585        let hint: [u8; 3] = hint
1586            .try_into()
1587            .map_err(|_| MobileMeshError::UnknownConversation)?;
1588        let (response, result) = oneshot::channel();
1589        self.commands
1590            .send(WorkerCommand::RequestIdentityByHint {
1591                conversation_address,
1592                hint: NodeHint(hint),
1593                response,
1594            })
1595            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1596        result
1597            .await
1598            .map_err(|_| MobileMeshError::SessionUnavailable)?
1599    }
1600
1601    /// Ask one repeater which repeaters it knows of, and return the whole
1602    /// listing.
1603    ///
1604    /// Unlike `discover_identities`, which scatters a request and lets the
1605    /// answers arrive as events, this is one node's own account of its
1606    /// neighborhood: a single addressed exchange, paged when it does not fit
1607    /// one frame, so it resolves to a list rather than a stream. Pages are
1608    /// followed here; the caller sees only the finished listing.
1609    pub async fn request_peer_repeaters(
1610        &self,
1611        peer: Vec<u8>,
1612    ) -> Result<Vec<MobileMeshPeerRepeaterRecord>, MobileMeshError> {
1613        let peer: [u8; 32] = peer.try_into().map_err(|_| MobileMeshError::InvalidPeer)?;
1614        let (response, result) = oneshot::channel();
1615        self.commands
1616            .send(WorkerCommand::PeerRepeaters {
1617                peer: PublicKey(peer),
1618                response,
1619            })
1620            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1621        result
1622            .await
1623            .map_err(|_| MobileMeshError::SessionUnavailable)?
1624    }
1625
1626    /// Set whether this phone answers Identity Requests with its own
1627    /// identity — the passive counterpart of [`discover_identities`]:
1628    /// discoverable phones show up in other people's Discover sessions.
1629    ///
1630    /// `name` is the display name carried in replies (truncated to the
1631    /// 24-byte wire limit). The session starts discoverable with no name;
1632    /// the app pushes the stored preference and name right after install
1633    /// and again whenever either changes. Replies are targeted
1634    /// authenticated unicasts, never broadcasts.
1635    /// Set the name carried on this phone's own group messages.
1636    ///
1637    /// A multicast reaches members holding no identity for us, so a group
1638    /// message says who sent it or arrives anonymous. Direct messages never
1639    /// carry it: the recipient authenticated us by key. Empty clears it.
1640    pub async fn set_chat_display_name(&self, name: String) -> Result<(), MobileMeshError> {
1641        let (response, result) = oneshot::channel();
1642        self.commands
1643            .send(WorkerCommand::SetChatDisplayName { name, response })
1644            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1645        result
1646            .await
1647            .map_err(|_| MobileMeshError::SessionUnavailable)
1648    }
1649
1650    pub async fn set_discoverable(
1651        &self,
1652        enabled: bool,
1653        name: Option<String>,
1654    ) -> Result<(), MobileMeshError> {
1655        let (response, result) = oneshot::channel();
1656        self.commands
1657            .send(WorkerCommand::SetDiscoverable {
1658                enabled,
1659                name,
1660                response,
1661            })
1662            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1663        result
1664            .await
1665            .map_err(|_| MobileMeshError::SessionUnavailable)
1666    }
1667
1668    /// Set the position this phone's identity carries, or `None` to stop
1669    /// sharing one.
1670    ///
1671    /// Reaches every *live* identity payload — advertisements, manual and
1672    /// scheduled, and Identity Request replies while discoverable — but
1673    /// never the shareable QR/URI bundle: that bundle is durable, and a
1674    /// position frozen into it would go stale and then travel wherever
1675    /// the QR is pasted. The coordinate is reduced to the cell named by
1676    /// `precision_bytes` before it is stored, so nothing finer ever sits
1677    /// in this session, whatever later reads it.
1678    pub async fn set_advertised_location(
1679        &self,
1680        location: Option<MobileMeshSharedLocationRecord>,
1681    ) -> Result<(), MobileMeshError> {
1682        let location = location.map(disclosed_cell).transpose()?;
1683        let (response, result) = oneshot::channel();
1684        self.commands
1685            .send(WorkerCommand::SetAdvertisedLocation { location, response })
1686            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1687        result
1688            .await
1689            .map_err(|_| MobileMeshError::SessionUnavailable)
1690    }
1691
1692    /// Report the route the MAC will use for the next frame sent to `peer`.
1693    ///
1694    /// Read-only: an unregistered peer reads as `Unknown` rather than being
1695    /// registered as a side effect of being inspected.
1696    pub async fn peer_route(
1697        &self,
1698        peer_address: String,
1699    ) -> Result<MobileMeshRouteRecord, MobileMeshError> {
1700        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
1701        let (response, result) = oneshot::channel();
1702        self.commands
1703            .send(WorkerCommand::PeerRoute { peer, response })
1704            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1705        result
1706            .await
1707            .map_err(|_| MobileMeshError::SessionUnavailable)
1708    }
1709
1710    /// Forget the route cached for `peer`, returning whether one was held.
1711    ///
1712    /// The peer, its keys, and its counters are untouched; only the learned
1713    /// path is discarded, so the next send starts over from flood delivery.
1714    pub async fn clear_peer_route(&self, peer_address: String) -> Result<bool, MobileMeshError> {
1715        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
1716        let (response, result) = oneshot::channel();
1717        self.commands
1718            .send(WorkerCommand::ClearPeerRoute { peer, response })
1719            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1720        result
1721            .await
1722            .map_err(|_| MobileMeshError::SessionUnavailable)
1723    }
1724
1725    /// Build and sign this phone's node-identity bundle without transmitting
1726    /// it, for embedding in the shareable `umsh:n:` URI and QR code.
1727    pub async fn sign_identity_bundle(
1728        &self,
1729        name: Option<String>,
1730        timestamp: Option<u32>,
1731    ) -> Result<Vec<u8>, MobileMeshError> {
1732        let (response, result) = oneshot::channel();
1733        self.commands
1734            .send(WorkerCommand::SignIdentityBundle {
1735                name,
1736                timestamp,
1737                response,
1738            })
1739            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1740        result
1741            .await
1742            .map_err(|_| MobileMeshError::SessionUnavailable)?
1743    }
1744
1745    pub async fn register_peers(&self, peer_addresses: Vec<String>) -> Result<(), MobileMeshError> {
1746        let peers = peer_addresses
1747            .iter()
1748            .map(|address| decode_peer(address).map_err(|_| MobileMeshError::InvalidPeer))
1749            .collect::<Result<Vec<_>, _>>()?;
1750        let (response, result) = oneshot::channel();
1751        self.commands
1752            .send(WorkerCommand::RegisterPeers { peers, response })
1753            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1754        result
1755            .await
1756            .map_err(|_| MobileMeshError::SessionUnavailable)?
1757    }
1758
1759    /// Remove peers from the live MAC. Idempotent: a peer that was never
1760    /// registered is already in the requested state, so it is not an error.
1761    /// A removed peer that transmits again may be auto-re-registered
1762    /// (unpinned) by the MAC — removal here tracks the app's stored peer
1763    /// list, it is not a block list.
1764    pub async fn remove_peers(&self, peer_addresses: Vec<String>) -> Result<(), MobileMeshError> {
1765        let peers = peer_addresses
1766            .iter()
1767            .map(|address| decode_peer(address).map_err(|_| MobileMeshError::InvalidPeer))
1768            .collect::<Result<Vec<_>, _>>()?;
1769        let (response, result) = oneshot::channel();
1770        self.commands
1771            .send(WorkerCommand::RemovePeers { peers, response })
1772            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1773        result
1774            .await
1775            .map_err(|_| MobileMeshError::SessionUnavailable)?
1776    }
1777
1778    /// Register channel keys with the live MAC so their traffic is accepted.
1779    ///
1780    /// Membership itself is persisted by the platform, which replays the whole
1781    /// joined set through this call when a session starts. Re-registering a
1782    /// channel already held is harmless.
1783    pub async fn register_channels(&self, keys: Vec<Vec<u8>>) -> Result<(), MobileMeshError> {
1784        let keys = decode_channel_keys(keys)?;
1785        let (response, result) = oneshot::channel();
1786        self.commands
1787            .send(WorkerCommand::RegisterChannels { keys, response })
1788            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1789        result
1790            .await
1791            .map_err(|_| MobileMeshError::SessionUnavailable)?
1792    }
1793
1794    /// Drop channel keys from the live MAC, so its traffic is no longer
1795    /// decrypted. Idempotent, like [`Self::remove_peers`].
1796    pub async fn remove_channels(&self, keys: Vec<Vec<u8>>) -> Result<(), MobileMeshError> {
1797        let keys = decode_channel_keys(keys)?;
1798        let (response, result) = oneshot::channel();
1799        self.commands
1800            .send(WorkerCommand::RemoveChannels { keys, response })
1801            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1802        result
1803            .await
1804            .map_err(|_| MobileMeshError::SessionUnavailable)?
1805    }
1806
1807    pub fn receive(&self, frame: MobileMeshRxRecord) -> Result<(), MobileMeshError> {
1808        if frame.data.is_empty() || frame.data.len() > MAX_FRAME_SIZE {
1809            return Err(MobileMeshError::SessionUnavailable);
1810        }
1811        self.commands
1812            .send(WorkerCommand::Receive(frame))
1813            .map_err(|_| MobileMeshError::SessionUnavailable)
1814    }
1815
1816    /// Report the actual physical radio result for an outbound
1817    /// frame. This is intentionally distinct from accepting the frame into the
1818    /// BLE/CRP queue: the MAC starts ACK and retry timing only after success.
1819    pub fn complete_outbound_frame(
1820        &self,
1821        frame_id: u64,
1822        transmitted: bool,
1823    ) -> Result<(), MobileMeshError> {
1824        if !transmitted {
1825            // A rejected frame fails the whole outbound batch. Poison before
1826            // releasing this frame's wait so the MAC drain cannot dispatch
1827            // the frames queued behind it (see fail_outbound_transmissions).
1828            self.transmit_completions.poison();
1829            self.commands
1830                .send(WorkerCommand::FailOutboundTransmissions)
1831                .map_err(|_| MobileMeshError::SessionUnavailable)?;
1832        }
1833        self.transmit_completions
1834            .complete(frame_id, transmitted)
1835            .then_some(())
1836            .ok_or(MobileMeshError::SessionUnavailable)
1837    }
1838
1839    pub async fn restore_chat(
1840        &self,
1841        checkpoints: Vec<MobileChatCheckpointRecord>,
1842    ) -> Result<(), MobileMeshError> {
1843        let (response, result) = oneshot::channel();
1844        self.commands
1845            .send(WorkerCommand::RestoreChat {
1846                checkpoints,
1847                response,
1848            })
1849            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1850        result
1851            .await
1852            .map_err(|_| MobileMeshError::SessionUnavailable)
1853    }
1854
1855    /// Compose a message into a conversation, addressed either by a peer's
1856    /// address or by a channel's conversation address.
1857    pub async fn compose_text(
1858        &self,
1859        conversation_address: String,
1860        client_token: u32,
1861        body: String,
1862    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1863        self.compose_chat(
1864            conversation_address,
1865            client_token,
1866            ChatComposeRequest::Text { body },
1867        )
1868        .await
1869    }
1870
1871    /// Compose an edit of a previously sent message. The original may come
1872    /// from an earlier app launch: its persisted `(wire_id, epoch)` is used
1873    /// when the facade session no longer holds a live handle, and the engine
1874    /// rejects it (`ChatComposeFailed`) if stream continuity was lost since.
1875    pub async fn compose_edit(
1876        &self,
1877        conversation_address: String,
1878        client_token: u32,
1879        original: MobileChatOriginalRef,
1880        body: String,
1881    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1882        self.compose_chat(
1883            conversation_address,
1884            client_token,
1885            ChatComposeRequest::Edit { original, body },
1886        )
1887        .await
1888    }
1889
1890    /// Compose a deletion (empty edit on the wire) of a previously sent
1891    /// message. Same original-reference rules as [`Self::compose_edit`].
1892    pub async fn compose_delete(
1893        &self,
1894        conversation_address: String,
1895        client_token: u32,
1896        original: MobileChatOriginalRef,
1897    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1898        self.compose_chat(
1899            conversation_address,
1900            client_token,
1901            ChatComposeRequest::Delete { original },
1902        )
1903        .await
1904    }
1905
1906    /// React to a message with a short emote body, or withdraw an earlier
1907    /// reaction by passing an empty body. A sender has at most one live
1908    /// reaction per message: sending another simply supersedes it, so there
1909    /// is nothing to edit or delete.
1910    ///
1911    /// Unlike an edit, the target may be a message the peer sent, and usually
1912    /// one persisted before this launch; the reference carries the direction
1913    /// and (for channel groups) the sender hint needed to name it.
1914    pub async fn compose_reaction(
1915        &self,
1916        conversation_address: String,
1917        client_token: u32,
1918        target: MobileChatRegardingRef,
1919        body: String,
1920    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
1921        self.compose_chat(
1922            conversation_address,
1923            client_token,
1924            ChatComposeRequest::Reaction { target, body },
1925        )
1926        .await
1927    }
1928
1929    pub async fn commit_chat_batch(&self, batch_id: u64) -> Result<(), MobileMeshError> {
1930        let (response, result) = oneshot::channel();
1931        self.commands
1932            .send(WorkerCommand::CommitChatBatch { batch_id, response })
1933            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1934        result
1935            .await
1936            .map_err(|_| MobileMeshError::SessionUnavailable)?
1937    }
1938
1939    pub async fn reject_chat_batch(
1940        &self,
1941        batch_id: u64,
1942        checkpoints: Vec<MobileChatCheckpointRecord>,
1943    ) -> Result<(), MobileMeshError> {
1944        let (response, result) = oneshot::channel();
1945        self.commands
1946            .send(WorkerCommand::RejectChatBatch {
1947                batch_id,
1948                checkpoints,
1949                response,
1950            })
1951            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1952        result
1953            .await
1954            .map_err(|_| MobileMeshError::SessionUnavailable)?
1955    }
1956
1957    pub fn apply_chat_archive_result(
1958        &self,
1959        request_id: u32,
1960        kind: MobileChatArchiveResultKind,
1961        payload: Vec<u8>,
1962    ) -> Result<(), MobileMeshError> {
1963        self.commands
1964            .send(WorkerCommand::ChatArchiveResult {
1965                request_id,
1966                kind,
1967                payload,
1968            })
1969            .map_err(|_| MobileMeshError::SessionUnavailable)
1970    }
1971
1972    pub fn acknowledge_chat_batch(&self, batch_id: u64) -> Result<(), MobileMeshError> {
1973        let mut pending = self
1974            .pending_chat_events
1975            .lock()
1976            .map_err(|_| MobileMeshError::SessionUnavailable)?;
1977        if pending.as_ref().is_some_and(|batch| batch.id == batch_id) {
1978            *pending = None;
1979            // Events that queued while this batch was outstanding could not
1980            // form a new batch; poke the listener so the platform drains
1981            // again now that the slot is free.
1982            self.wake.notify();
1983        }
1984        Ok(())
1985    }
1986
1987    /// Fail every chat transmission currently owned by the mobile radio
1988    /// bridge. The platform calls this when ULCP-link delivery failed
1989    /// after the MAC had accepted the frames, ensuring optimistic UI rows do
1990    /// not remain in `Sending` indefinitely.
1991    pub fn fail_outbound_transmissions(&self) -> Result<(), MobileMeshError> {
1992        // Poison before anything else: from this instant until the worker
1993        // processes the command below (the sole clearer), every frame the
1994        // MAC's in-progress drain loop tries to hand to the platform is
1995        // suppressed instead of dispatched. Without this, releasing the
1996        // blocked transmit lets the drain advance to the next queued frame,
1997        // which samples the post-bump generation and goes out as if healthy.
1998        self.transmit_completions.poison();
1999        self.commands
2000            .send(WorkerCommand::FailOutboundTransmissions)
2001            .map_err(|_| MobileMeshError::SessionUnavailable)?;
2002        // Release a transmit wait already in progress; the drain it unblocks
2003        // is defused by the poison above.
2004        self.transmit_completions.fail_all();
2005        Ok(())
2006    }
2007
2008    /// Register (or replace) the listener that is told when this session
2009    /// has new data for `poll_update`. If data is already pending, the
2010    /// listener fires immediately.
2011    pub fn set_wake_listener(&self, listener: Arc<dyn MobileMeshWakeListener>) {
2012        self.wake.set_listener(Some(listener));
2013    }
2014
2015    pub fn clear_wake_listener(&self) {
2016        self.wake.set_listener(None);
2017    }
2018
2019    pub fn poll_update(&self) -> MobileMeshSessionUpdateRecord {
2020        // Re-arm before draining: anything enqueued mid-drain triggers a
2021        // fresh notification rather than being silently swallowed.
2022        self.wake.drained();
2023        let mut outbound_frames = Vec::new();
2024        if let Ok(receiver) = self.outbound.lock() {
2025            outbound_frames.extend(receiver.try_iter());
2026        }
2027        let mut ping_events = Vec::new();
2028        if let Ok(receiver) = self.events.lock() {
2029            ping_events.extend(receiver.try_iter());
2030        }
2031        let mut management_events = Vec::new();
2032        if let Ok(receiver) = self.management.lock() {
2033            management_events.extend(receiver.try_iter());
2034        }
2035        let mut advertisement_events = Vec::new();
2036        if let Ok(receiver) = self.advertisements.lock() {
2037            advertisement_events.extend(receiver.try_iter());
2038        }
2039        let mut peer_heard_events = Vec::new();
2040        if let Ok(receiver) = self.peer_heard.lock() {
2041            peer_heard_events.extend(receiver.try_iter());
2042        }
2043        let mut chat_mutations = Vec::new();
2044        let mut chat_deliveries = Vec::new();
2045        let mut chat_archive_lookups = Vec::new();
2046        let mut chat_sender_resolutions = Vec::new();
2047        let mut chat_diagnostics = Vec::new();
2048        let mut chat_batch_id = None;
2049        if let Ok(mut pending) = self.pending_chat_events.lock() {
2050            if pending.is_none()
2051                && let Ok(receiver) = self.chat_events.lock()
2052            {
2053                let events = receiver.try_iter().collect::<Vec<_>>();
2054                if !events.is_empty()
2055                    && let Ok(mut next) = self.next_chat_batch_id.lock()
2056                {
2057                    let id = *next;
2058                    *next = next.wrapping_add(1).max(1);
2059                    *pending = Some(PendingChatEventBatch { id, events });
2060                }
2061            }
2062            if let Some(batch) = pending.as_ref() {
2063                chat_batch_id = Some(batch.id);
2064                for event in batch.events.iter().cloned() {
2065                    match event {
2066                        MobileChatWorkerEvent::Mutation(record) => chat_mutations.push(record),
2067                        MobileChatWorkerEvent::Delivery(record) => chat_deliveries.push(record),
2068                        MobileChatWorkerEvent::ArchiveLookup(record) => {
2069                            chat_archive_lookups.push(record);
2070                        }
2071                        MobileChatWorkerEvent::SenderResolution(record) => {
2072                            chat_sender_resolutions.push(record);
2073                        }
2074                        MobileChatWorkerEvent::Diagnostic(record) => chat_diagnostics.push(record),
2075                    }
2076                }
2077            }
2078        }
2079        MobileMeshSessionUpdateRecord {
2080            outbound_frames,
2081            ping_events,
2082            management_events,
2083            advertisement_events,
2084            peer_heard_events,
2085            chat_batch_id,
2086            chat_mutations,
2087            chat_deliveries,
2088            chat_archive_lookups,
2089            chat_sender_resolutions,
2090            chat_diagnostics,
2091        }
2092    }
2093}
2094
2095impl MobileMeshSession {
2096    /// Allocate an operation identifier and hand the request to the
2097    /// worker, which owns the administrator engine.
2098    fn begin_management(
2099        &self,
2100        peer_address: String,
2101        request: ManagementRequest,
2102    ) -> Result<u64, MobileMeshError> {
2103        let peer = decode_peer(&peer_address).map_err(|_| MobileMeshError::InvalidPeer)?;
2104        let operation_id = self.next_operation_id()?;
2105        self.commands
2106            .send(WorkerCommand::Manage {
2107                operation_id,
2108                peer,
2109                request,
2110            })
2111            .map_err(|_| MobileMeshError::SessionUnavailable)?;
2112        Ok(operation_id)
2113    }
2114
2115    fn next_operation_id(&self) -> Result<u64, MobileMeshError> {
2116        let mut next = self
2117            .next_operation_id
2118            .lock()
2119            .map_err(|_| MobileMeshError::SessionUnavailable)?;
2120        let current = *next;
2121        *next = next.wrapping_add(1).max(1);
2122        Ok(current)
2123    }
2124
2125    async fn compose_chat(
2126        &self,
2127        conversation_address: String,
2128        client_token: u32,
2129        request: ChatComposeRequest,
2130    ) -> Result<MobileChatComposeBatchRecord, MobileMeshError> {
2131        // Resolved on the worker, which owns the channel registry an address
2132        // may need to be interpreted against.
2133        let (response, result) = oneshot::channel();
2134        self.commands
2135            .send(WorkerCommand::ComposeChat {
2136                conversation_address,
2137                client_token,
2138                request,
2139                response,
2140            })
2141            .map_err(|_| MobileMeshError::SessionUnavailable)?;
2142        result
2143            .await
2144            .map_err(|_| MobileMeshError::SessionUnavailable)?
2145    }
2146
2147    /// Construct a session whose worker runtime starts with tokio's clock
2148    /// paused (test builds only). Timers auto-advance whenever the worker is
2149    /// otherwise idle, so multi-second protocol deadlines — MAC ACK
2150    /// timeouts, ping timeouts, repair timers — resolve in wall-clock
2151    /// milliseconds without changing any production code path.
2152    #[cfg(test)]
2153    async fn new_with_virtual_time(
2154        identity: Arc<MobileIdentity>,
2155        counter_store: Arc<MobileCounterStore>,
2156    ) -> Result<Arc<Self>, MobileMeshError> {
2157        Self::build(identity, counter_store, true).await
2158    }
2159
2160    async fn build(
2161        identity: Arc<MobileIdentity>,
2162        counter_store: Arc<MobileCounterStore>,
2163        virtual_time: bool,
2164    ) -> Result<Arc<Self>, MobileMeshError> {
2165        let (commands, command_rx) = mpsc::unbounded_channel();
2166        let wake = Arc::new(WakeSignal::new());
2167        let (outbound_tx, outbound) = std_mpsc::channel();
2168        let (event_tx, events) = std_mpsc::channel();
2169        let (management_tx, management) = std_mpsc::channel();
2170        let (advertisement_tx, advertisements) = std_mpsc::channel();
2171        let (peer_heard_tx, peer_heard) = std_mpsc::channel();
2172        let (chat_event_tx, chat_events) = std_mpsc::channel();
2173        let outbound_tx = NotifyingSender {
2174            tx: outbound_tx,
2175            wake: wake.clone(),
2176        };
2177        let event_tx = NotifyingSender {
2178            tx: event_tx,
2179            wake: wake.clone(),
2180        };
2181        let management_tx = NotifyingSender {
2182            tx: management_tx,
2183            wake: wake.clone(),
2184        };
2185        let advertisement_tx = NotifyingSender {
2186            tx: advertisement_tx,
2187            wake: wake.clone(),
2188        };
2189        let peer_heard_tx = NotifyingSender {
2190            tx: peer_heard_tx,
2191            wake: wake.clone(),
2192        };
2193        let chat_event_tx = NotifyingSender {
2194            tx: chat_event_tx,
2195            wake: wake.clone(),
2196        };
2197        let (ready_tx, ready_rx) = oneshot::channel();
2198        let worker_identity = identity.take_for_session()?;
2199        let local_key = *worker_identity.public_key();
2200        let transmit_completions = Arc::new(BridgeTransmitCompletions::new());
2201        let worker_transmit_completions = transmit_completions.clone();
2202
2203        std::thread::Builder::new()
2204            .name("umsh-mobile-mesh".to_owned())
2205            // The whole 64-peer MAC lives inside the worker future, and the
2206            // future is polled (and moved during construction) on this
2207            // thread's stack. The platform default (512 KiB–2 MiB for
2208            // secondary threads) is not enough headroom for that.
2209            .stack_size(16 * 1024 * 1024)
2210            .spawn(move || {
2211                let mut builder = tokio::runtime::Builder::new_current_thread();
2212                builder.enable_time();
2213                #[cfg(test)]
2214                if virtual_time {
2215                    builder.start_paused(true);
2216                }
2217                #[cfg(not(test))]
2218                let _ = virtual_time;
2219                let runtime = match builder.build() {
2220                    Ok(runtime) => runtime,
2221                    Err(_) => {
2222                        let _ = ready_tx.send(Err(MobileMeshError::SessionUnavailable));
2223                        return;
2224                    }
2225                };
2226                let local = tokio::task::LocalSet::new();
2227                // Boxed so the future's state — which embeds the MAC and its
2228                // peer tables by value — lives on the heap rather than in
2229                // this thread's stack frame.
2230                local.block_on(
2231                    &runtime,
2232                    Box::pin(run_worker(
2233                        worker_identity,
2234                        SharedCounterStore(counter_store),
2235                        command_rx,
2236                        outbound_tx,
2237                        worker_transmit_completions,
2238                        event_tx,
2239                        management_tx,
2240                        advertisement_tx,
2241                        peer_heard_tx,
2242                        chat_event_tx,
2243                        ready_tx,
2244                    )),
2245                );
2246            })
2247            .map_err(|_| MobileMeshError::SessionUnavailable)?;
2248
2249        ready_rx
2250            .await
2251            .map_err(|_| MobileMeshError::SessionUnavailable)??;
2252        Ok(Arc::new(Self {
2253            local_key,
2254            commands,
2255            outbound: Mutex::new(outbound),
2256            transmit_completions,
2257            events: Mutex::new(events),
2258            management: Mutex::new(management),
2259            advertisements: Mutex::new(advertisements),
2260            peer_heard: Mutex::new(peer_heard),
2261            chat_events: Mutex::new(chat_events),
2262            pending_chat_events: Mutex::new(None),
2263            next_chat_batch_id: Mutex::new(1),
2264            next_operation_id: Mutex::new(1),
2265            wake,
2266        }))
2267    }
2268}
2269
2270impl Drop for MobileMeshSession {
2271    fn drop(&mut self) {
2272        self.transmit_completions.fail_all();
2273        let _ = self.commands.send(WorkerCommand::Shutdown);
2274    }
2275}
2276
2277/// Reduce a platform reading to the cell it discloses.
2278///
2279/// The integer path (`from_e7`) rather than the float one: it is exact at
2280/// every precision the format carries, so the cell a listener decodes is
2281/// the cell that was chosen, not its floating-point neighbour.
2282fn disclosed_cell(record: MobileMeshSharedLocationRecord) -> Result<NodeLocation, MobileMeshError> {
2283    if !(1..=MAX_PRECISION).contains(&record.precision_bytes)
2284        || !record.latitude_degrees.is_finite()
2285        || record.latitude_degrees.abs() > 90.0
2286        || !record.longitude_degrees.is_finite()
2287        || record.longitude_degrees.abs() > 180.0
2288    {
2289        return Err(MobileMeshError::InvalidLocation);
2290    }
2291    Ok(NodeLocation::from_e7(
2292        (record.latitude_degrees * 1e7).round() as i32,
2293        (record.longitude_degrees * 1e7).round() as i32,
2294        record.precision_bytes,
2295    ))
2296}
2297
2298/// The identity profile the phone's Identity Request responder serves:
2299/// the same role Chat / Mobile + Text messages statement the signed
2300/// advertisement makes, with the display name truncated identically and
2301/// the same disclosed location, so a node that asks and a node that
2302/// listens hear one description.
2303fn phone_identity_profile(
2304    public_key: PublicKey,
2305    name: Option<&str>,
2306    location: Option<NodeLocation>,
2307) -> NodeIdentityProfile {
2308    let mut profile = NodeIdentityProfile::new(
2309        public_key,
2310        NodeRole::Chat,
2311        NodeCapabilities::MOBILE | NodeCapabilities::TEXT_MESSAGES,
2312    );
2313    profile.name = name
2314        .map(|name| {
2315            let mut end = name.len().min(24);
2316            while !name.is_char_boundary(end) {
2317                end -= 1;
2318            }
2319            name[..end].to_owned()
2320        })
2321        .filter(|name| !name.is_empty());
2322    profile.location = location;
2323    profile
2324}
2325
2326/// Build the signed standalone node-identity bundle for this phone: role
2327/// Chat, capabilities Mobile + Text messages, optional display name
2328/// (truncated to the 24-byte wire limit on a character boundary), and the
2329/// disclosed location when one is being shared. The result is ROLE
2330/// through the trailing 64-byte signature, without the payload-type byte.
2331async fn build_signed_identity_bundle(
2332    signer: &SoftwareIdentity,
2333    name: Option<&str>,
2334    timestamp: Option<u32>,
2335    location: Option<NodeLocation>,
2336) -> Result<Vec<u8>, MobileMeshError> {
2337    let name = name
2338        .map(|name| {
2339            let mut end = name.len().min(24);
2340            while !name.is_char_boundary(end) {
2341                end -= 1;
2342            }
2343            name[..end].to_owned()
2344        })
2345        .filter(|name| !name.is_empty());
2346    let payload = NodeIdentityPayload {
2347        role: NodeRole::Chat,
2348        capabilities: NodeCapabilities::MOBILE | NodeCapabilities::TEXT_MESSAGES,
2349        name,
2350        location,
2351        altitude_m: None,
2352        timestamp,
2353        supported_regions: None,
2354        nonce: None,
2355        signature: None,
2356    };
2357    let mut buf = [0u8; 192];
2358    let len = payload
2359        .encode_for_signing(&mut buf)
2360        .map_err(|_| MobileMeshError::SendFailed)?;
2361    let signature = signer
2362        .sign(&buf[..len])
2363        .await
2364        .map_err(|_| MobileMeshError::SendFailed)?;
2365    let mut bundle = buf[..len].to_vec();
2366    bundle.extend_from_slice(&signature);
2367    Ok(bundle)
2368}
2369
2370/// Flood-hop budget on a management request. A device worth managing
2371/// remotely is one that is not in the room, so an unrouted first request
2372/// has to be able to travel.
2373const MANAGEMENT_FLOOD_HOPS: u8 = 5;
2374
2375/// How many properties one batch of a whole-device read asks for.
2376///
2377/// Small enough that a device answers most batches in one frame, and that
2378/// a batch lost to a timeout is cheap to have lost; a batch whose answer
2379/// does overflow is continued by the binding's own cursors, so this is not
2380/// a correctness bound.
2381const SYNC_BATCH: usize = 8;
2382
2383/// Encode a management request, which must fit one Node Management
2384/// payload. The frame's TID is ignored over this binding — the envelope
2385/// token is what correlates a response — so every request carries zero.
2386fn encode_management(
2387    build: impl FnOnce(&mut [u8]) -> Result<usize, umsh_ulcp::frame::WriteError>,
2388) -> Result<Vec<u8>, MobileMeshError> {
2389    let mut buf = vec![0u8; umsh_node_mgmt::REQUEST_MAX];
2390    let len = build(&mut buf).map_err(|_| MobileMeshError::InvalidRequest)?;
2391    buf.truncate(len);
2392    Ok(buf)
2393}
2394
2395/// A read of a named set of properties, one batch at a time.
2396///
2397/// The caller says what to ask for, which is what keeps a screenful of
2398/// settings from costing a whole-device read. Everything out of an
2399/// administrator's reach is dropped before a single frame goes on the
2400/// air.
2401struct FetchCrawl {
2402    /// Whether the device answers multi-property requests. A hint from
2403    /// the caller, corrected by the device the moment it declines one.
2404    multi: bool,
2405    /// What the outstanding request asked for, in order.
2406    asked: Vec<u32>,
2407    /// What is still to be asked for.
2408    pending: VecDeque<u32>,
2409    /// Every answer so far, refusals included: a property the device
2410    /// would not report is a different thing from one nobody asked for,
2411    /// and only the caller can tell what that means for its screen.
2412    answers: Vec<MobileMeshManagementAnswerRecord>,
2413}
2414
2415impl FetchCrawl {
2416    fn new(properties: Vec<u32>, multi_hint: bool) -> Self {
2417        let mut pending: Vec<u32> = properties
2418            .into_iter()
2419            .filter(|&key| ids::admin_reachable(key))
2420            .collect();
2421        pending.dedup();
2422        Self {
2423            multi: multi_hint,
2424            asked: Vec::new(),
2425            pending: pending.into(),
2426            answers: Vec::new(),
2427        }
2428    }
2429
2430    /// The next request, or `None` when there is nothing left to ask.
2431    fn next_request(&mut self) -> Result<Option<Vec<u8>>, MobileMeshError> {
2432        let batch = if self.multi { SYNC_BATCH } else { 1 };
2433        self.asked = self
2434            .pending
2435            .drain(..batch.min(self.pending.len()))
2436            .collect();
2437        match self.asked.as_slice() {
2438            [] => Ok(None),
2439            [key] => encode_management(|buf| frame::prop_get(buf, 0, *key)).map(Some),
2440            keys => encode_management(|buf| frame::prop_multi_get(buf, 0, keys)).map(Some),
2441        }
2442    }
2443
2444    /// Take in one answer.
2445    fn receive(&mut self, reply: &[u8]) -> Result<(), MobileMeshError> {
2446        if let [key] = self.asked.as_slice() {
2447            let key = *key;
2448            match umsh_ulcp::reply::property(key, reply) {
2449                Ok(answer) => self.answers.push(answer_record(key, answer)),
2450                Err(_) => return Err(MobileMeshError::InvalidRequest),
2451            }
2452            return Ok(());
2453        }
2454
2455        let asked = core::mem::take(&mut self.asked);
2456        let Ok(entries) = umsh_ulcp::reply::entries(&asked, reply) else {
2457            // The device declined the command rather than the properties,
2458            // which is what one without `CAP_CMD_MULTI` does. Ask again one
2459            // at a time; the hint said otherwise, but the device is the
2460            // authority on itself.
2461            self.multi = false;
2462            for key in asked.into_iter().rev() {
2463                self.pending.push_front(key);
2464            }
2465            return Ok(());
2466        };
2467        let mut answered = 0usize;
2468        for (key, answer) in entries.flatten() {
2469            answered += 1;
2470            self.answers.push(answer_record(key, answer));
2471        }
2472        // A device stops before its answer overflows rather than
2473        // truncating one, so whatever it did not reach is simply asked for
2474        // again. An answer that reached nothing would ask forever, so that
2475        // batch is broken up instead.
2476        if answered == 0 {
2477            self.multi = false;
2478        }
2479        for key in asked.into_iter().skip(answered).rev() {
2480            self.pending.push_front(key);
2481        }
2482        Ok(())
2483    }
2484}
2485
2486/// What the phone is doing with one device, and how to read what comes
2487/// back.
2488enum ManagementPlan {
2489    One(ReplyShape),
2490    Fetch(FetchCrawl),
2491}
2492
2493/// One outstanding management operation.
2494struct ManagementJob<M: MacBackend> {
2495    operation_id: u64,
2496    peer: PublicKey,
2497    manager: umsh_node_mgmt::NodeManager<M>,
2498    plan: ManagementPlan,
2499    /// The last REMAINING reported, so progress is emitted when the device
2500    /// says something new rather than on every service call.
2501    reported_remaining: Option<u32>,
2502}
2503
2504impl<M: MacBackend> ManagementJob<M> {
2505    fn event(&self, outcome: MobileMeshManagementOutcome) -> MobileMeshManagementEventRecord {
2506        MobileMeshManagementEventRecord {
2507            operation_id: self.operation_id,
2508            peer_address: encode_peer_address(&self.peer),
2509            outcome,
2510            answers: Vec::new(),
2511            status_code: None,
2512            remaining_octets: self.manager.remaining(),
2513            properties_remaining: match &self.plan {
2514                ManagementPlan::Fetch(crawl) => Some(crawl.pending.len() as u32),
2515                ManagementPlan::One(_) => None,
2516            },
2517        }
2518    }
2519
2520    /// A report of what the device is still holding back, the first time
2521    /// it says so and whenever the number changes.
2522    fn progress(&mut self) -> Option<MobileMeshManagementEventRecord> {
2523        let remaining = self.manager.remaining();
2524        if remaining.is_none() || remaining == self.reported_remaining {
2525            return None;
2526        }
2527        self.reported_remaining = remaining;
2528        Some(self.event(MobileMeshManagementOutcome::Progress))
2529    }
2530
2531    /// Read a finished exchange. `None` means another exchange was begun
2532    /// and the operation continues.
2533    fn settle(
2534        &mut self,
2535        outcome: umsh_node_mgmt::Outcome,
2536        now_ms: u64,
2537    ) -> Option<MobileMeshManagementEventRecord> {
2538        match outcome {
2539            umsh_node_mgmt::Outcome::Failed(umsh_node_mgmt::Failure::TimedOut) => {
2540                return Some(self.event(MobileMeshManagementOutcome::TimedOut));
2541            }
2542            umsh_node_mgmt::Outcome::Failed(_) => {
2543                return Some(self.event(MobileMeshManagementOutcome::Failed));
2544            }
2545            umsh_node_mgmt::Outcome::NoResponse => {
2546                return Some(match self.plan {
2547                    // Only a reset-class command is answered by nothing.
2548                    ManagementPlan::One(ReplyShape::Acknowledgment) => {
2549                        self.event(MobileMeshManagementOutcome::Acknowledged)
2550                    }
2551                    _ => self.event(MobileMeshManagementOutcome::Failed),
2552                });
2553            }
2554            // The reply is the reassembly buffer, which the manager hands
2555            // out whole; its length is not needed separately.
2556            umsh_node_mgmt::Outcome::Replied { .. } => {}
2557        }
2558
2559        match &mut self.plan {
2560            ManagementPlan::One(shape) => {
2561                let shape = shape.clone();
2562                Some(self.replied(&shape))
2563            }
2564            ManagementPlan::Fetch(crawl) => {
2565                if crawl.receive(self.manager.reply()).is_err() {
2566                    return Some(self.event(MobileMeshManagementOutcome::Failed));
2567                }
2568                match crawl.next_request() {
2569                    Ok(Some(request)) => match self.manager.begin(&request, now_ms) {
2570                        Ok(()) => {
2571                            self.reported_remaining = None;
2572                            None
2573                        }
2574                        Err(_) => Some(self.event(MobileMeshManagementOutcome::Failed)),
2575                    },
2576                    Ok(None) => Some(self.fetched()),
2577                    Err(_) => Some(self.event(MobileMeshManagementOutcome::Failed)),
2578                }
2579            }
2580        }
2581    }
2582
2583    /// Report a single exchange's reply, read against the shape that was
2584    /// asked for.
2585    fn replied(&self, shape: &ReplyShape) -> MobileMeshManagementEventRecord {
2586        let reply = self.manager.reply();
2587        let mut event = self.event(MobileMeshManagementOutcome::Replied);
2588        match shape {
2589            ReplyShape::Property(key) => match umsh_ulcp::reply::property(*key, reply) {
2590                Ok(answer) => event.answers.push(answer_record(*key, answer)),
2591                Err(_) => return self.event(MobileMeshManagementOutcome::Failed),
2592            },
2593            ReplyShape::Entries(keys) => match umsh_ulcp::reply::entries(keys, reply) {
2594                Ok(entries) => {
2595                    for (key, answer) in entries.flatten() {
2596                        event.answers.push(answer_record(key, answer));
2597                    }
2598                }
2599                // A device without `CAP_CMD_MULTI` declines the command
2600                // itself, which is an answer about the request rather than
2601                // about any one property.
2602                Err(_) => event.status_code = umsh_ulcp::reply::status_of(reply).map(|s| s.0),
2603            },
2604            // A reset the device answered anyway — `CMD_RESTORE` with no
2605            // snapshot to restore — reports like any other status.
2606            ReplyShape::Status | ReplyShape::Acknowledgment => {
2607                match umsh_ulcp::reply::status_of(reply) {
2608                    Some(status) => event.status_code = Some(status.0),
2609                    None => return self.event(MobileMeshManagementOutcome::Failed),
2610                }
2611            }
2612        }
2613        event
2614    }
2615
2616    /// Hand back everything a finished crawl collected.
2617    ///
2618    /// Undecoded on purpose: what these values mean is the caller's
2619    /// question, and it asked for a particular set of properties because
2620    /// it already knew what it wanted with them.
2621    fn fetched(&mut self) -> MobileMeshManagementEventRecord {
2622        let ManagementPlan::Fetch(crawl) = &mut self.plan else {
2623            return self.event(MobileMeshManagementOutcome::Failed);
2624        };
2625        let answers = core::mem::take(&mut crawl.answers);
2626        let mut event = self.event(MobileMeshManagementOutcome::Replied);
2627        event.answers = answers;
2628        event
2629    }
2630}
2631
2632/// Present the answers to a management read as the property frames the
2633/// ULCP inspectors read.
2634///
2635/// A mesh answer and a GATT property frame carry the same thing — a
2636/// property and what the device said it is worth — so the decoders are
2637/// the same decoders. Refusals drop out here: they are answers *about* a
2638/// property rather than values of one, and the event still carries them
2639/// for a caller that needs to know which.
2640#[uniffi::export]
2641pub fn ulcp_records_from_answers(
2642    answers: Vec<MobileMeshManagementAnswerRecord>,
2643) -> Vec<UlcpPropertyFrameRecord> {
2644    answers
2645        .into_iter()
2646        .filter_map(|answer| {
2647            Some(crate::ulcp::ulcp_property_record(
2648                answer.property_id,
2649                answer.value?,
2650            ))
2651        })
2652        .collect()
2653}
2654
2655fn answer_record(
2656    property_id: u32,
2657    answer: umsh_ulcp::reply::Answer<'_>,
2658) -> MobileMeshManagementAnswerRecord {
2659    MobileMeshManagementAnswerRecord {
2660        property_id,
2661        value: answer.value().map(<[u8]>::to_vec),
2662        status_code: answer.status().map(|status| status.0),
2663    }
2664}
2665
2666/// Report an operation that never started.
2667fn emit_management_failure(
2668    events: &NotifyingSender<MobileMeshManagementEventRecord>,
2669    operation_id: u64,
2670    peer: &PublicKey,
2671) {
2672    let _ = events.send(MobileMeshManagementEventRecord {
2673        operation_id,
2674        peer_address: encode_peer_address(peer),
2675        outcome: MobileMeshManagementOutcome::Failed,
2676        answers: Vec::new(),
2677        status_code: None,
2678        remaining_octets: None,
2679        properties_remaining: None,
2680    });
2681}
2682
2683/// Register the target and put the first request on the air.
2684async fn start_management<M: MacBackend>(
2685    node: &LocalNode<M>,
2686    operation_id: u64,
2687    peer: PublicKey,
2688    request: ManagementRequest,
2689    now_ms: u64,
2690    token_seed: u16,
2691) -> Option<ManagementJob<M>> {
2692    let connection = node.peer(peer).await.ok()?;
2693    let mut manager = umsh_node_mgmt::NodeManager::new(connection, token_seed);
2694    // An acknowledgment is what completes a reset and what turns an
2695    // unreachable device into an early answer; a flood budget and a trace
2696    // route are what get a first request to a device no route is known
2697    // for, and teach the MAC the way back.
2698    *manager.send_options_mut() = SendOptions::default()
2699        .with_ack_requested(true)
2700        .with_flood_hops(MANAGEMENT_FLOOD_HOPS)
2701        .with_trace_route();
2702    let (plan, request) = match request {
2703        ManagementRequest::One { frame, shape } => (ManagementPlan::One(shape), frame),
2704        ManagementRequest::Fetch {
2705            property_ids,
2706            multi_hint,
2707        } => {
2708            let mut crawl = FetchCrawl::new(property_ids, multi_hint);
2709            let request = crawl.next_request().ok()??;
2710            (ManagementPlan::Fetch(crawl), request)
2711        }
2712    };
2713    manager.begin(&request, now_ms).ok()?;
2714    Some(ManagementJob {
2715        operation_id,
2716        peer,
2717        manager,
2718        plan,
2719        reported_remaining: None,
2720    })
2721}
2722
2723/// Carry the outstanding operation as far as it goes right now, clearing
2724/// it and reporting once it ends.
2725async fn service_management<M: MacBackend>(
2726    job: &mut Option<ManagementJob<M>>,
2727    now_ms: u64,
2728    events: &NotifyingSender<MobileMeshManagementEventRecord>,
2729    token: &mut u16,
2730) {
2731    let Some(active) = job.as_mut() else {
2732        return;
2733    };
2734    loop {
2735        let progress = active.manager.service(now_ms).await;
2736        // The service call may have advanced the token ledger, and the
2737        // next operation's manager is seeded from here — a token issued
2738        // twice is answered with the earlier exchange's retained
2739        // response instead of running.
2740        *token = active.manager.counter();
2741        match progress {
2742            Err(_) => {
2743                let _ = events.send(active.event(MobileMeshManagementOutcome::Failed));
2744                *job = None;
2745                return;
2746            }
2747            Ok(umsh_node_mgmt::Progress::Waiting { .. }) => {
2748                if let Some(progress) = active.progress() {
2749                    let _ = events.send(progress);
2750                }
2751                return;
2752            }
2753            Ok(umsh_node_mgmt::Progress::Done(outcome)) => match active.settle(outcome, now_ms) {
2754                Some(event) => {
2755                    let _ = events.send(event);
2756                    *job = None;
2757                    return;
2758                }
2759                None => {
2760                    // Another exchange of the same operation. A crawl says
2761                    // so at every batch boundary, which is the only sign
2762                    // of life a long read gives before it finishes.
2763                    let _ = events.send(active.event(MobileMeshManagementOutcome::Progress));
2764                    continue;
2765                }
2766            },
2767        }
2768    }
2769}
2770
2771async fn run_worker(
2772    identity: SoftwareIdentity,
2773    counter_store: SharedCounterStore,
2774    mut commands: mpsc::UnboundedReceiver<WorkerCommand>,
2775    outbound: NotifyingSender<MobileMeshOutboundFrameRecord>,
2776    transmit_completions: Arc<BridgeTransmitCompletions>,
2777    events: NotifyingSender<MobileMeshPingEventRecord>,
2778    management_events: NotifyingSender<MobileMeshManagementEventRecord>,
2779    advertisements: NotifyingSender<MobileMeshAdvertisementRecord>,
2780    peer_heard: NotifyingSender<MobileMeshPeerHeardRecord>,
2781    chat_events: NotifyingSender<MobileChatWorkerEvent>,
2782    ready: oneshot::Sender<Result<(), MobileMeshError>>,
2783) {
2784    let local_key = *identity.public_key();
2785    // The MAC takes ownership of the identity below; standalone bundle
2786    // signing (advertisements, QR bundles) uses this retained clone.
2787    let signer = identity.clone();
2788    let (inbound_tx, inbound_rx) = mpsc::unbounded_channel();
2789    let worker_completions = transmit_completions.clone();
2790    let radio = BridgeRadio {
2791        inbound: inbound_rx,
2792        outbound,
2793        completions: transmit_completions,
2794    };
2795    let mac = MobileMac::new(
2796        radio,
2797        CryptoEngine::new(SoftwareAes, SoftwareSha256),
2798        MobileClock::new(),
2799        rand::rng(),
2800        counter_store,
2801        RepeaterConfig::default(),
2802        OperatingPolicy::default(),
2803    );
2804    let cell = AsyncRefCell::new(mac);
2805    let handle = MacHandle::new(&cell);
2806    let identity_id = match handle.add_identity(identity).await {
2807        Ok(id) => id,
2808        Err(_) => {
2809            let _ = ready.send(Err(MobileMeshError::SessionUnavailable));
2810            return;
2811        }
2812    };
2813    if handle.load_persisted_counter(identity_id).await.is_err() {
2814        let _ = ready.send(Err(MobileMeshError::CounterPersistenceFailed));
2815        return;
2816    }
2817    // A stranger's authenticated unicast — an Identity Request reply, a
2818    // first contact — names its sender with a full 32-byte source key.
2819    // Auto-registration (unpinned, LRU-evictable) is what lets the MAC
2820    // verify such a frame at all; without it the reply to our own
2821    // Discover solicitation is dropped unheard. Device firmware runs
2822    // with the same setting.
2823    handle.set_auto_register_full_key_peers(true).await;
2824
2825    let mut host = Host::new(handle);
2826    let node = host.add_node(identity_id);
2827    // What this phone currently says about itself. The session starts
2828    // discoverable with no name and no location; the app pushes the
2829    // stored preferences via `set_discoverable` and
2830    // `set_advertised_location` right after install. Held here because
2831    // the responder profile is rebuilt whole whenever any of it changes,
2832    // and the advertisement arms read the location at send time.
2833    let mut discoverable = true;
2834    let mut responder_name: Option<String> = None;
2835    let mut advertised_location: Option<NodeLocation> = None;
2836    node.enable_identity_responder_default(phone_identity_profile(
2837        local_key,
2838        responder_name.as_deref(),
2839        advertised_location,
2840    ));
2841    // Held outside the chat state: rejecting a batch rebuilds the reducer,
2842    // and the channels the platform registered must outlive that.
2843    let channel_registry = Rc::new(RefCell::new(ChannelRegistry::default()));
2844    let mut chat = MobileChatState::new(local_key, channel_registry.clone());
2845    // Registered before every other receive handler: dispatch stops at the
2846    // first handler that claims a packet, and presence is true of packets
2847    // that something else goes on to claim. It never claims one itself.
2848    let peer_heard_events = peer_heard.clone();
2849    let peer_heard_subscription = node.on_receive(move |packet| {
2850        let _ = peer_heard_events.send(MobileMeshPeerHeardRecord {
2851            peer_address: packet.from_key().map(|peer| encode_peer_address(&peer)),
2852            node_hint: packet.from_hint().map(|hint| hint.0.to_vec()),
2853            source_authenticated: packet.source_authenticated(),
2854        });
2855        false
2856    });
2857    let inbound_text = Rc::new(RefCell::new(Vec::<InboundText>::new()));
2858    let inbound_text_callback = inbound_text.clone();
2859    let text_channels = channel_registry.clone();
2860    let echo_events = chat_events.clone();
2861    let text_subscription = node.on_receive(move |packet| {
2862        if packet.payload_type() != PayloadType::TextMessage {
2863            return false;
2864        }
2865        // Our own multicast, relayed back to us. Every group send carries our
2866        // full source address, so a repeater's copy arrives naming us — but
2867        // it is the message we already have, not a second one, and the
2868        // transcript must not show it twice.
2869        //
2870        // It is still evidence: something out there received our frame and
2871        // forwarded it, which is the only reachability signal a multicast
2872        // ever produces. Claim it so nothing else interprets it, and report
2873        // how far it travelled.
2874        //
2875        // Scoped to multicast, because a directed frame naming us is not an
2876        // echo. A unicast reaches this handler only when it also named a
2877        // local identity as its destination, which makes it a message we
2878        // addressed to ourselves; that belongs in the transcript like any
2879        // other. It takes a neighbor willing to repeat it to arrive at all,
2880        // since a radio never hears its own transmission.
2881        if packet.packet_family() == PacketFamily::Multicast && packet.from_key() == Some(local_key)
2882        {
2883            let distance = match packet.hop_count() {
2884                Some(hops) => format!("after {hops} hop(s)"),
2885                None => "over a source route".to_string(),
2886            };
2887            let _ = echo_events.send(MobileChatWorkerEvent::Diagnostic(format!(
2888                "own multicast relayed back {distance}"
2889            )));
2890            return true;
2891        }
2892        // A channel frame names its channel by the key that authenticated it,
2893        // so the tag is derived from that key rather than looked up by the
2894        // two-byte identifier the frame carried — distinct keys may share an
2895        // identifier, and only the key that decrypted the frame is the truth.
2896        let channel_tag = packet.channel().map(|channel| {
2897            (
2898                crate::channel_tag(channel.key()),
2899                text_channels
2900                    .borrow()
2901                    .contains(&crate::channel_tag(channel.key())),
2902            )
2903        });
2904        // The same rule read from the receiving end: emergency traffic that is
2905        // not readable by every node in range, or that does not name its
2906        // sender outright, is not accepted at all. A frame that fails either
2907        // test is dropped rather than shown unmarked — a message the reader
2908        // would act on in an emergency must not arrive with its origin or its
2909        // reach in question.
2910        //
2911        // Both checks live here, at the chat layer, rather than under the MAC:
2912        // the requirement the spec states is about chat messages, and the MAC
2913        // is deliberately incurious about what it carries.
2914        if let Some((tag, _)) = channel_tag
2915            && tag == crate::emergency_channel_tag()
2916            && (packet.encrypted() || packet.from_key().is_none())
2917        {
2918            let reason = if packet.encrypted() {
2919                "encrypted"
2920            } else {
2921                "missing its full source key"
2922            };
2923            let _ = echo_events.send(MobileChatWorkerEvent::Diagnostic(format!(
2924                "dropped an emergency-channel text frame: {reason}"
2925            )));
2926            return false;
2927        }
2928        let source = match (packet.packet_family(), channel_tag) {
2929            (PacketFamily::Unicast, _) => match packet.from_key() {
2930                Some(peer) => InboundTextSource::Direct { peer },
2931                None => return false,
2932            },
2933            // Membership is what the channel MIC authenticates; the hint is
2934            // the only sender identity a multicast frame must carry.
2935            (PacketFamily::Multicast, Some((channel, true))) => match packet.from_hint() {
2936                Some(hint) => InboundTextSource::ChannelGroup {
2937                    channel,
2938                    hint,
2939                    full_key: packet.from_key(),
2940                },
2941                None => return false,
2942            },
2943            // Without a full key there is nobody to attribute the message to,
2944            // and nobody to answer a repair request to.
2945            (PacketFamily::BlindUnicast, Some((channel, true))) => match packet.from_key() {
2946                Some(peer) => InboundTextSource::ChannelDirect { channel, peer },
2947                None => return false,
2948            },
2949            _ => return false,
2950        };
2951        inbound_text_callback.borrow_mut().push(InboundText {
2952            source,
2953            payload: packet.payload().to_vec(),
2954            received_at_ms: packet.received_at_ms(),
2955            rx: MobileChatRxMetadataRecord {
2956                rssi_dbm: packet.rssi(),
2957                snr_centibels: packet.snr().map(|snr| snr.as_centibels()),
2958                lqi: packet.lqi().map(|lqi| lqi.get()),
2959                hop_count: packet.hop_count(),
2960                route_hints: packet
2961                    .trace_route_hops()
2962                    .map(|hop| hop.0.to_vec())
2963                    .collect(),
2964                source_authenticated: packet.source_authenticated(),
2965            },
2966        });
2967        true
2968    });
2969    let advertisement_events = advertisements.clone();
2970    let advertisement_subscription = node.on_receive(move |packet| {
2971        if packet.payload_type() != PayloadType::NodeIdentity {
2972            return false;
2973        }
2974        // Hint-only sources cannot name a key to verify the bundle's
2975        // signature against, so they are not surfaced at all.
2976        let Some(peer) = packet.from_key() else {
2977            return false;
2978        };
2979        let _ = advertisement_events.send(MobileMeshAdvertisementRecord {
2980            peer_address: encode_peer_address(&peer),
2981            payload: packet.payload().to_vec(),
2982            source_authenticated: packet.source_authenticated(),
2983        });
2984        true
2985    });
2986    // One device at a time: an administrator has one exchange outstanding
2987    // with a device, and a phone has no reason to be managing two at once.
2988    let mut management: Option<ManagementJob<_>> = None;
2989    // One advancing token counter for every management exchange this
2990    // worker will ever run. An operation consumes as many tokens as it
2991    // has batches and continuations, and a device holds every answered
2992    // token against retransmission — a request reusing one is answered
2993    // with the old exchange's response and never runs. Seeded randomly
2994    // so a fresh session cannot land on tokens a device still retains
2995    // from the previous one.
2996    let mut management_token: u16 = rand::random();
2997    let mut in_flight_chat = Vec::<InFlightChatTransmission>::new();
2998    // How each channel member was last reached, so an identity request can be
2999    // routed by evidence rather than flooded at the default budget.
3000    let mut member_routes = BTreeMap::<(ChannelTag, [u8; 3]), MemberRoute>::new();
3001    let mut chat_pipeline_ready = BTreeSet::<[u8; 32]>::new();
3002    let mut pending_chat_transmissions = VecDeque::<umsh_text::engine::Transmission>::new();
3003    let pending = Rc::new(RefCell::new(BTreeMap::<[u8; 32], u64>::new()));
3004    let pong_pending = pending.clone();
3005    let pong_events = events.clone();
3006    let pong_subscription = node.on_pong_with_metadata(move |peer, metadata| {
3007        if let Some(operation_id) = pong_pending.borrow_mut().remove(&peer.0) {
3008            let _ = pong_events.send(MobileMeshPingEventRecord {
3009                operation_id,
3010                outcome: MobileMeshPingOutcome::Reply,
3011                round_trip_milliseconds: Some(metadata.round_trip_ms),
3012                hop_count: metadata.hop_count,
3013                route_hints: metadata
3014                    .route_hints
3015                    .iter()
3016                    .map(|hint| hint.0.to_vec())
3017                    .collect(),
3018                rssi_dbm: metadata.rssi_dbm,
3019                snr_centibels: metadata.snr_centibels,
3020                lqi: metadata.lqi,
3021            });
3022        }
3023    });
3024    let timeout_pending = pending.clone();
3025    let timeout_events = events.clone();
3026    let timeout_subscription = node.on_ping_timeout(move |peer| {
3027        if let Some(operation_id) = timeout_pending.borrow_mut().remove(&peer.0) {
3028            let _ = timeout_events.send(MobileMeshPingEventRecord {
3029                operation_id,
3030                outcome: MobileMeshPingOutcome::TimedOut,
3031                round_trip_milliseconds: None,
3032                hop_count: None,
3033                route_hints: Vec::new(),
3034                rssi_dbm: None,
3035                snr_centibels: None,
3036                lqi: None,
3037            });
3038        }
3039    });
3040    let _subscriptions = (
3041        pong_subscription,
3042        timeout_subscription,
3043        peer_heard_subscription,
3044        text_subscription,
3045        advertisement_subscription,
3046    );
3047    let _ = ready.send(Ok(()));
3048    let mut protocol_timeout_tick = tokio::time::interval(Duration::from_millis(50));
3049    protocol_timeout_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
3050
3051    // The worker runs as two sibling loops polled by one outer select.
3052    //
3053    // `Radio::transmit` awaits the device's physical TX completion while
3054    // `MacHandle` holds the coordinator borrow, so the pump must keep being
3055    // polled while a command arm waits on that borrow — a single select
3056    // whose arm bodies suspend the task would deadlock: the arm waits on
3057    // the borrow, and the pump future that owns it is never re-polled to
3058    // release it. As sibling futures of the outer select, the pump makes
3059    // progress whenever the command loop is waiting.
3060    let inbound_ready = tokio::sync::Notify::new();
3061    let timeout_servicer = host.protocol_timeout_servicer();
3062
3063    let pump_loop = async {
3064        loop {
3065            if host.pump_once().await.is_err() {
3066                return;
3067            }
3068            if !inbound_text.borrow().is_empty() {
3069                inbound_ready.notify_one();
3070            }
3071        }
3072    };
3073
3074    let command_loop = async {
3075        loop {
3076            tokio::select! {
3077                biased;
3078                command = commands.recv() => {
3079                    match command {
3080                        Some(WorkerCommand::RegisterPeers { peers, response }) => {
3081                            let mut result = Ok(());
3082                            for peer in peers {
3083                                if node.peer(peer).await.is_err() {
3084                                    result = Err(MobileMeshError::SendFailed);
3085                                    break;
3086                                }
3087                            }
3088                            let _ = response.send(result);
3089                        }
3090                        Some(WorkerCommand::RemovePeers { peers, response }) => {
3091                            for peer in peers {
3092                                // Not-found is success: the peer is absent
3093                                // either way.
3094                                let _ = node.remove_peer(&peer).await;
3095                            }
3096                            let _ = response.send(Ok(()));
3097                        }
3098                        Some(WorkerCommand::RegisterChannels { keys, response }) => {
3099                            let mut result = Ok(());
3100                            for key in keys {
3101                                // Named and private channels are the same
3102                                // thing here: the app already holds the
3103                                // derived key either way.
3104                                if node.join(&umsh_node::Channel::private(key, "")).await.is_err() {
3105                                    result = Err(MobileMeshError::ChannelCapacity);
3106                                    break;
3107                                }
3108                                channel_registry
3109                                    .borrow_mut()
3110                                    .register(crate::channel_tag(&key), key);
3111                            }
3112                            let _ = response.send(result);
3113                        }
3114                        Some(WorkerCommand::RemoveChannels { keys, response }) => {
3115                            for key in keys {
3116                                // Not-joined is success, as for peers.
3117                                let _ = node.leave(&umsh_node::Channel::private(key, "")).await;
3118                                channel_registry
3119                                    .borrow_mut()
3120                                    .remove(&crate::channel_tag(&key));
3121                            }
3122                            let _ = response.send(Ok(()));
3123                        }
3124                        Some(WorkerCommand::Ping { operation_id, peer, timeout_ms }) => {
3125                            if pending.borrow().contains_key(&peer.0) {
3126                                emit_ping_failure(&events, operation_id);
3127                                continue;
3128                            }
3129                            let result = match node.peer(peer).await {
3130                                Ok(connection) => connection
3131                                    .ping(
3132                                        6,
3133                                        // Trace route for the path, trace
3134                                        // signal for what each hop of it
3135                                        // cost. A ping that reports only a
3136                                        // round-trip time says a link is bad
3137                                        // without saying where.
3138                                        &SendOptions::default()
3139                                            .with_flood_hops(5)
3140                                            .with_trace_route()
3141                                            .with_trace_signal()
3142                                            .with_mic_size(umsh_node::PING_MIC_SIZE),
3143                                        timeout_ms,
3144                                    )
3145                                    .await
3146                                    .map(|_| ())
3147                                    .map_err(|_| MobileMeshError::SendFailed),
3148                                Err(_) => Err(MobileMeshError::SendFailed),
3149                            };
3150                            if result.is_ok() {
3151                                pending.borrow_mut().insert(peer.0, operation_id);
3152                                // This write is caused by a real authenticated send. It is
3153                                // deliberately not performed during startup. Do not move it
3154                                // into session construction: reboot loops must remain read-only.
3155                                if handle.service_counter_persistence().await.is_err() {
3156                                    emit_ping_failure(&events, operation_id);
3157                                    return;
3158                                }
3159                            }
3160                            if result.is_err() {
3161                                emit_ping_failure(&events, operation_id);
3162                            }
3163                        }
3164                        Some(WorkerCommand::Manage { operation_id, peer, request }) => {
3165                            if management.is_some() {
3166                                emit_management_failure(&management_events, operation_id, &peer);
3167                                continue;
3168                            }
3169                            let now_ms = handle.now_ms().await;
3170                            management = start_management(
3171                                &node,
3172                                operation_id,
3173                                peer,
3174                                request,
3175                                now_ms,
3176                                management_token,
3177                            )
3178                            .await;
3179                            if management.is_none() {
3180                                emit_management_failure(&management_events, operation_id, &peer);
3181                                continue;
3182                            }
3183                            service_management(
3184                                &mut management,
3185                                now_ms,
3186                                &management_events,
3187                                &mut management_token,
3188                            )
3189                            .await;
3190                            if handle.service_counter_persistence().await.is_err() {
3191                                return;
3192                            }
3193                        }
3194                        Some(WorkerCommand::Advertise { name, timestamp, scheduled, response }) => {
3195                            let result = match build_signed_identity_bundle(
3196                                &signer,
3197                                name.as_deref(),
3198                                timestamp,
3199                                advertised_location,
3200                            )
3201                            .await
3202                            {
3203                                Ok(bundle) => {
3204                                    let mut frame = Vec::with_capacity(bundle.len() + 1);
3205                                    frame.push(PayloadType::NodeIdentity as u8);
3206                                    frame.extend_from_slice(&bundle);
3207                                    // Full source either way, so the detached
3208                                    // signature is checkable.
3209                                    let options = SendOptions::default().with_full_source();
3210                                    let options = if scheduled {
3211                                        // No flood budget and no trace: a
3212                                        // restatement on a timer belongs to
3213                                        // the neighbours who can hear it.
3214                                        options.no_flood()
3215                                    } else {
3216                                        // Trace route so a listener learns a
3217                                        // path back to this phone from the
3218                                        // same frame, and trace signal so it
3219                                        // learns what that path costs — the
3220                                        // two pair entry for entry.
3221                                        options.with_trace_route().with_trace_signal()
3222                                    };
3223                                    node.send_all(&frame, &options)
3224                                        .await
3225                                        .map(|_| ())
3226                                        .map_err(|_| MobileMeshError::SendFailed)
3227                                }
3228                                Err(error) => Err(error),
3229                            };
3230                            if result.is_ok()
3231                                && handle.service_counter_persistence().await.is_err()
3232                            {
3233                                let _ = response.send(Err(MobileMeshError::SendFailed));
3234                                return;
3235                            }
3236                            let _ = response.send(result);
3237                        }
3238                        Some(WorkerCommand::Beacon { response }) => {
3239                            // Trace route to learn the path, trace signal to
3240                            // learn what that path costs.
3241                            let result = node
3242                                .send_all(
3243                                    &[],
3244                                    &SendOptions::default()
3245                                        .with_flood_hops(BEACON_FLOOD_HOPS)
3246                                        .with_trace_route()
3247                                        .with_trace_signal(),
3248                                )
3249                                .await
3250                                .map(|_| ())
3251                                .map_err(|_| MobileMeshError::SendFailed);
3252                            if result.is_ok()
3253                                && handle.service_counter_persistence().await.is_err()
3254                            {
3255                                let _ = response.send(Err(MobileMeshError::SendFailed));
3256                                return;
3257                            }
3258                            let _ = response.send(result);
3259                        }
3260                        Some(WorkerCommand::SignIdentityBundle { name, timestamp, response }) => {
3261                            // Never the location: this bundle outlives the
3262                            // moment — pasted into messages, printed as a
3263                            // QR — and a position frozen into it goes
3264                            // stale and then travels wherever it does.
3265                            let result = build_signed_identity_bundle(
3266                                &signer,
3267                                name.as_deref(),
3268                                timestamp,
3269                                None,
3270                            )
3271                            .await;
3272                            let _ = response.send(result);
3273                        }
3274                        Some(WorkerCommand::RequestIdentity { peer, response }) => {
3275                            let result = match node.peer(peer).await {
3276                                Ok(connection) => connection
3277                                    .request_identity(
3278                                        &SendOptions::default()
3279                                            .with_flood_hops(5)
3280                                            .with_ack_requested(false),
3281                                    )
3282                                    .await
3283                                    .map(|_| ())
3284                                    .map_err(|_| MobileMeshError::SendFailed),
3285                                Err(_) => Err(MobileMeshError::SendFailed),
3286                            };
3287                            // A real authenticated send advances the frame counter;
3288                            // persist it before acknowledging, as the ping/advertise
3289                            // paths do.
3290                            if result.is_ok()
3291                                && handle.service_counter_persistence().await.is_err()
3292                            {
3293                                let _ = response.send(Err(MobileMeshError::SendFailed));
3294                                return;
3295                            }
3296                            let _ = response.send(result);
3297                        }
3298                        Some(WorkerCommand::RequestIdentityByHint {
3299                            conversation_address,
3300                            hint,
3301                            response,
3302                        }) => {
3303                            let channel = chat
3304                                .parse_conversation_address(&conversation_address)
3305                                .and_then(|conversation| match conversation {
3306                                    ConversationKey::ChannelGroup { channel } => Some(channel),
3307                                    _ => None,
3308                                });
3309                            let result = match channel {
3310                                Some(channel) => {
3311                                    let route = member_routes.get(&(channel, hint.0)).cloned();
3312                                    let mut nonce_bytes = [0u8; 4];
3313                                    handle.fill_random(&mut nonce_bytes).await;
3314                                    request_identity_over_channel(
3315                                        &node,
3316                                        &channel_registry,
3317                                        channel,
3318                                        hint,
3319                                        u32::from_be_bytes(nonce_bytes),
3320                                        route,
3321                                    )
3322                                    .await
3323                                }
3324                                None => Err(MobileMeshError::UnknownConversation),
3325                            };
3326                            if result.is_ok()
3327                                && handle.service_counter_persistence().await.is_err()
3328                            {
3329                                let _ = response.send(Err(MobileMeshError::SendFailed));
3330                                return;
3331                            }
3332                            let _ = response.send(result);
3333                        }
3334                        Some(WorkerCommand::PeerRepeaters { peer, mut response }) => {
3335                            let result = tokio::select! {
3336                                result = collect_peer_repeaters(&node, &handle, peer) => result,
3337                                // The asker let go of its half. The pages still
3338                                // outstanding are for nobody, and waiting out
3339                                // their timeouts would hold this loop against
3340                                // every command behind them.
3341                                _ = response.closed() => continue,
3342                            };
3343                            let _ = response.send(result);
3344                        }
3345                        Some(WorkerCommand::SetChatDisplayName { name, response }) => {
3346                            chat.engine.set_local_handle(&name);
3347                            let _ = response.send(());
3348                        }
3349                        Some(WorkerCommand::SetDiscoverable { enabled, name, response }) => {
3350                            discoverable = enabled;
3351                            responder_name = name;
3352                            if discoverable {
3353                                node.enable_identity_responder_default(phone_identity_profile(
3354                                    local_key,
3355                                    responder_name.as_deref(),
3356                                    advertised_location,
3357                                ));
3358                            } else {
3359                                node.disable_identity_responder();
3360                            }
3361                            let _ = response.send(());
3362                        }
3363                        Some(WorkerCommand::SetAdvertisedLocation { location, response }) => {
3364                            advertised_location = location;
3365                            // The installed profile is a copy, so a live
3366                            // responder is reinstalled to serve the new
3367                            // position. Not discoverable means not
3368                            // installed — nothing to refresh.
3369                            if discoverable {
3370                                node.enable_identity_responder_default(phone_identity_profile(
3371                                    local_key,
3372                                    responder_name.as_deref(),
3373                                    advertised_location,
3374                                ));
3375                            }
3376                            let _ = response.send(());
3377                        }
3378                        Some(WorkerCommand::DiscoverIdentities {
3379                            role_code,
3380                            capability_bits,
3381                            node_hint,
3382                            source_route,
3383                            response,
3384                        }) => {
3385                            let result = async {
3386                                let mut builder = umsh_node::mac_command::IdentityRequestBuilder::new();
3387                                let mut nonce_bytes = [0u8; 4];
3388                                handle.fill_random(&mut nonce_bytes).await;
3389                                builder = builder
3390                                    .nonce(u32::from_be_bytes(nonce_bytes))
3391                                    .map_err(|_| MobileMeshError::SendFailed)?;
3392                                // Options are emitted in ascending key order, so
3393                                // the hint filter goes between the nonce and
3394                                // the role.
3395                                if let Some(hint) = node_hint.as_deref() {
3396                                    builder = builder
3397                                        .filter_hint_prefix(hint)
3398                                        .map_err(|_| MobileMeshError::SendFailed)?;
3399                                }
3400                                if let Some(role) = role_code {
3401                                    builder = builder
3402                                        .filter_role(NodeRole::from_byte(role))
3403                                        .map_err(|_| MobileMeshError::SendFailed)?;
3404                                }
3405                                // A broadcast request must carry at least one
3406                                // filter option. An unrestricted ask carries a
3407                                // zero-bit capability filter, which every node
3408                                // satisfies — but a hint filter already narrows
3409                                // the ask, and padding it would only cost bytes.
3410                                let unfiltered = role_code.is_none() && node_hint.is_none();
3411                                let capability_bits =
3412                                    capability_bits.or(if unfiltered { Some(0) } else { None });
3413                                if let Some(bits) = capability_bits {
3414                                    builder = builder
3415                                        .filter_caps(NodeCapabilities::from_bits_truncate(bits))
3416                                        .map_err(|_| MobileMeshError::SendFailed)?;
3417                                }
3418                                let options_block = builder.build();
3419                                let cmd = umsh_node::MacCommand::IdentityRequest {
3420                                    options: &options_block,
3421                                };
3422                                let mut frame = [0u8; 128];
3423                                frame[0] = PayloadType::MacCommand as u8;
3424                                let length = umsh_node::mac_command::encode(&cmd, &mut frame[1..])
3425                                    .map_err(|_| MobileMeshError::SendFailed)?
3426                                    + 1;
3427                                // Full source lets a stranger unicast back.
3428                                let mut options = SendOptions::default().with_full_source();
3429                                if !source_route.is_empty() {
3430                                    let hops = source_route
3431                                        .iter()
3432                                        .map(|hint| {
3433                                            <[u8; 2]>::try_from(hint.as_slice())
3434                                                .map(umsh_core::RouterHint)
3435                                                .map_err(|_| MobileMeshError::SendFailed)
3436                                        })
3437                                        .collect::<Result<Vec<_>, _>>()?;
3438                                    // Unlike a hint-filtered ask over a
3439                                    // channel, this one must never fall back to
3440                                    // flooding. A role- or capability-filtered
3441                                    // request is answered by every node it
3442                                    // reaches, and even a hint-filtered one is
3443                                    // aimed at a particular place rather than
3444                                    // at the mesh: a route we cannot express is
3445                                    // a failure, not a license to broadcast.
3446                                    options = options
3447                                        .try_with_source_route(&hops)
3448                                        .map_err(|_| MobileMeshError::SendFailed)?
3449                                        // The trace the request accumulates is
3450                                        // the answering strangers' only path
3451                                        // home: a broadcast teaches the MAC no
3452                                        // route, and the reply carries no flood
3453                                        // budget.
3454                                        .with_trace_route();
3455                                }
3456                                // Last, always: try_with_source_route back-fills
3457                                // a flood budget from the route length, and any
3458                                // FHOPS field at all makes the far end drop an
3459                                // unhinted solicitation. A hint filter would
3460                                // license a flood budget, but this ask is aimed
3461                                // and has no use for one.
3462                                let options = options.no_flood();
3463                                node.send_all(&frame[..length], &options)
3464                                    .await
3465                                    .map(|_| ())
3466                                    .map_err(|_| MobileMeshError::SendFailed)
3467                            }
3468                            .await;
3469                            if result.is_ok()
3470                                && handle.service_counter_persistence().await.is_err()
3471                            {
3472                                let _ = response.send(Err(MobileMeshError::SendFailed));
3473                                return;
3474                            }
3475                            let _ = response.send(result);
3476                        }
3477                        Some(WorkerCommand::PeerRoute { peer, response }) => {
3478                            let _ = response.send(node.peer_route(&peer).await.into());
3479                        }
3480                        Some(WorkerCommand::ClearPeerRoute { peer, response }) => {
3481                            let _ = response.send(node.clear_peer_route(&peer).await);
3482                        }
3483                        Some(WorkerCommand::RestoreChat { checkpoints, response }) => {
3484                            chat.restore(&checkpoints, handle.now_ms().await);
3485                            let _ = response.send(());
3486                        }
3487                        Some(WorkerCommand::ComposeChat {
3488                            conversation_address,
3489                            client_token,
3490                            request,
3491                            response,
3492                        }) => {
3493                            // Rejecting a persisted batch rebuilds the reducer from
3494                            // durable checkpoints. Keep that recovery operation
3495                            // unambiguous by allowing only one uncommitted compose.
3496                            let conversation =
3497                                chat.parse_conversation_address(&conversation_address);
3498                            let result = if !chat.pending_batches.is_empty() {
3499                                Err(MobileMeshError::OperationInProgress)
3500                            } else if let Some(conversation) = conversation {
3501                                let now_ms = handle.now_ms().await;
3502                                let composed = match &request {
3503                                    ChatComposeRequest::Text { body } => {
3504                                        chat.compose_text(conversation, client_token, body, now_ms)
3505                                    }
3506                                    ChatComposeRequest::Edit { original, body } => chat.compose_edit(
3507                                        conversation,
3508                                        client_token,
3509                                        original,
3510                                        body,
3511                                        now_ms,
3512                                    ),
3513                                    ChatComposeRequest::Delete { original } => chat.compose_delete(
3514                                        conversation,
3515                                        client_token,
3516                                        original,
3517                                        now_ms,
3518                                    ),
3519                                    ChatComposeRequest::Reaction { target, body } => chat
3520                                        .compose_reaction(
3521                                            conversation,
3522                                            client_token,
3523                                            target,
3524                                            body,
3525                                            now_ms,
3526                                        ),
3527                                };
3528                                match composed {
3529                                    Ok(composed) => {
3530                                        for delivery in composed.deliveries {
3531                                            let _ = chat_events.send(
3532                                                MobileChatWorkerEvent::Delivery(delivery),
3533                                            );
3534                                        }
3535                                        for diagnostic in composed.diagnostics {
3536                                            let _ = chat_events.send(
3537                                                MobileChatWorkerEvent::Diagnostic(diagnostic),
3538                                            );
3539                                        }
3540                                        Ok(composed.record)
3541                                    }
3542                                    Err(()) => Err(MobileMeshError::ChatComposeFailed),
3543                                }
3544                            } else {
3545                                // Either the address is malformed, or it names
3546                                // a channel this session does not hold a key
3547                                // for — from here those are the same thing.
3548                                Err(MobileMeshError::UnknownConversation)
3549                            };
3550                            let _ = response.send(result);
3551                        }
3552                        Some(WorkerCommand::CommitChatBatch { batch_id, response }) => {
3553                            let result = match chat.pending_batches.remove(&batch_id) {
3554                                Some(batch) => {
3555                                    let now_ms = handle.now_ms().await;
3556                                    let sent = queue_chat_transmissions(
3557                                        &node,
3558                                        batch.transmissions,
3559                                        &mut pending_chat_transmissions,
3560                                        &mut in_flight_chat,
3561                                        &chat_pipeline_ready,
3562                                        &channel_registry,
3563                                        &mut chat,
3564                                        now_ms,
3565                                    )
3566                                    .await;
3567                                    publish_chat_drain(chat.drain(), &chat_events);
3568                                    if sent > 0
3569                                        && handle.service_counter_persistence().await.is_err()
3570                                    {
3571                                        Err(MobileMeshError::CounterPersistenceFailed)
3572                                    } else {
3573                                        Ok(())
3574                                    }
3575                                }
3576                                None => Err(MobileMeshError::ChatBatchMissing),
3577                            };
3578                            let fatal = result == Err(MobileMeshError::CounterPersistenceFailed);
3579                            let _ = response.send(result);
3580                            if fatal {
3581                                return;
3582                            }
3583                        }
3584                        Some(WorkerCommand::RejectChatBatch {
3585                            batch_id,
3586                            checkpoints,
3587                            response,
3588                        }) => {
3589                            let result = match chat.pending_batches.remove(&batch_id) {
3590                                Some(batch) => {
3591                                    for transmission in batch.transmissions {
3592                                        chat.engine.transmit_update(
3593                                            transmission.transmission_id,
3594                                            DeliveryState::Failed,
3595                                            handle.now_ms().await,
3596                                        );
3597                                    }
3598                                    publish_chat_drain(chat.drain(), &chat_events);
3599                                    chat = MobileChatState::new(
3600                                        local_key,
3601                                        channel_registry.clone(),
3602                                    );
3603                                    for diagnostic in
3604                                        chat.restore(&checkpoints, handle.now_ms().await)
3605                                    {
3606                                        let _ = chat_events.send(
3607                                            MobileChatWorkerEvent::Diagnostic(diagnostic),
3608                                        );
3609                                    }
3610                                    Ok(())
3611                                }
3612                                None => Err(MobileMeshError::ChatBatchMissing),
3613                            };
3614                            let _ = response.send(result);
3615                        }
3616                        Some(WorkerCommand::ChatArchiveResult {
3617                            request_id,
3618                            kind,
3619                            payload,
3620                        }) => {
3621                            let now_ms = handle.now_ms().await;
3622                            match kind {
3623                                MobileChatArchiveResultKind::Found => chat.engine.archive_result(
3624                                    request_id,
3625                                    ArchiveResult::Found { payload: &payload },
3626                                    now_ms,
3627                                ),
3628                                MobileChatArchiveResultKind::Deleted => chat.engine.archive_result(
3629                                    request_id,
3630                                    ArchiveResult::Deleted,
3631                                    now_ms,
3632                                ),
3633                                MobileChatArchiveResultKind::Evicted => chat.engine.archive_result(
3634                                    request_id,
3635                                    ArchiveResult::Evicted,
3636                                    now_ms,
3637                                ),
3638                                MobileChatArchiveResultKind::Unknown => chat.engine.archive_result(
3639                                    request_id,
3640                                    ArchiveResult::Unknown,
3641                                    now_ms,
3642                                ),
3643                            }
3644                            let drain = chat.drain();
3645                            let transmissions = drain.transmissions.clone();
3646                            publish_chat_drain(drain, &chat_events);
3647                            if !transmissions.is_empty() || !pending_chat_transmissions.is_empty() {
3648                                let sent = queue_chat_transmissions(
3649                                    &node,
3650                                    transmissions,
3651                                    &mut pending_chat_transmissions,
3652                                    &mut in_flight_chat,
3653                                    &chat_pipeline_ready,
3654                                    &channel_registry,
3655                                    &mut chat,
3656                                    now_ms,
3657                                )
3658                                .await;
3659                                publish_chat_drain(chat.drain(), &chat_events);
3660                                if sent > 0 && handle.service_counter_persistence().await.is_err() {
3661                                    return;
3662                                }
3663                            }
3664                        }
3665                        Some(WorkerCommand::FailOutboundTransmissions) => {
3666                            let now_ms = handle.now_ms().await;
3667                            for transmission in pending_chat_transmissions.drain(..) {
3668                                chat.engine.transmit_update(
3669                                    transmission.transmission_id,
3670                                    DeliveryState::Failed,
3671                                    now_ms,
3672                                );
3673                            }
3674                            for transmission in in_flight_chat.drain(..) {
3675                                if let Some(receipt) = transmission.ticket.receipt() {
3676                                    let _ = handle.cancel_pending_ack(identity_id, receipt).await;
3677                                }
3678                                chat.engine.transmit_update(
3679                                    transmission.transmission_id,
3680                                    DeliveryState::Failed,
3681                                    now_ms,
3682                                );
3683                            }
3684                            publish_chat_drain(chat.drain(), &chat_events);
3685                            // Every frame the failure covered is now cancelled;
3686                            // new transmissions may reach the platform again.
3687                            worker_completions.clear_poison();
3688                        }
3689                        Some(WorkerCommand::Receive(record)) => {
3690                            let _ = inbound_tx.send(InboundFrame { record });
3691                        }
3692                        Some(WorkerCommand::Shutdown) | None => return,
3693                    }
3694                }
3695                _ = inbound_ready.notified() => {
3696                    let received = inbound_text.borrow_mut().drain(..).collect::<Vec<_>>();
3697                    for text in received {
3698                        let received_at_ms = match text.received_at_ms {
3699                            Some(value) => value,
3700                            None => handle.now_ms().await,
3701                        };
3702                        // The envelope's sender is what the engine keys a
3703                        // stream by. A multicast member is always the claimed
3704                        // hint, with the full key passed alongside: naming the
3705                        // key here instead would split one member into two
3706                        // streams the moment a frame omitted it.
3707                        let (envelope, sender_full_key) = match text.source {
3708                            InboundTextSource::Direct { peer } => (
3709                                Envelope {
3710                                    path: DeliveryPath::Unicast,
3711                                    conversation: ConversationKey::Direct { peer },
3712                                    sender: SenderScope::Peer(peer),
3713                                },
3714                                Some(peer),
3715                            ),
3716                            InboundTextSource::ChannelGroup {
3717                                channel,
3718                                hint,
3719                                full_key,
3720                            } => {
3721                                if let Some(peer) = full_key {
3722                                    if let Some(resolution) =
3723                                        chat.resolve_member(channel, hint, peer)
3724                                    {
3725                                        let _ = chat_events.send(
3726                                            MobileChatWorkerEvent::SenderResolution(resolution),
3727                                        );
3728                                    }
3729                                }
3730                                remember_member_route(
3731                                    &mut member_routes,
3732                                    channel,
3733                                    hint,
3734                                    &text.rx,
3735                                );
3736                                (
3737                                    Envelope {
3738                                        path: DeliveryPath::Multicast,
3739                                        conversation: ConversationKey::ChannelGroup { channel },
3740                                        sender: SenderScope::ClaimedMember(hint),
3741                                    },
3742                                    full_key,
3743                                )
3744                            }
3745                            InboundTextSource::ChannelDirect { channel, peer } => (
3746                                Envelope {
3747                                    path: DeliveryPath::BlindUnicast,
3748                                    conversation: ConversationKey::ChannelDirect { channel, peer },
3749                                    sender: SenderScope::Peer(peer),
3750                                },
3751                                Some(peer),
3752                            ),
3753                        };
3754                        let _ = chat.engine.receive(
3755                            &envelope,
3756                            sender_full_key,
3757                            &text.payload,
3758                            received_at_ms,
3759                        );
3760                        let mut drain = chat.drain();
3761                        // This drain belongs to exactly one frame, so the
3762                        // records it produced are the ones that frame caused.
3763                        attach_rx_metadata(&mut drain.mutations, &text.rx);
3764                        let transmissions = drain.transmissions.clone();
3765                        publish_chat_drain(drain, &chat_events);
3766                        if !transmissions.is_empty() || !pending_chat_transmissions.is_empty() {
3767                            let sent = queue_chat_transmissions(
3768                                &node,
3769                                transmissions,
3770                                &mut pending_chat_transmissions,
3771                                &mut in_flight_chat,
3772                                &chat_pipeline_ready,
3773                                &channel_registry,
3774                                &mut chat,
3775                                received_at_ms,
3776                            )
3777                            .await;
3778                            publish_chat_drain(chat.drain(), &chat_events);
3779                            if sent > 0 && handle.service_counter_persistence().await.is_err() {
3780                                return;
3781                            }
3782                        }
3783                    }
3784                }
3785                _ = protocol_timeout_tick.tick() => {
3786                    timeout_servicer.service().await;
3787                    let now_ms = handle.now_ms().await;
3788                    if management.is_some() {
3789                        // Retries, cursor continuations, and the batches of
3790                        // a crawl all leave on this tick. Persistence is
3791                        // serviced alongside because each of those is a
3792                        // real authenticated send.
3793                        service_management(
3794                            &mut management,
3795                            now_ms,
3796                            &management_events,
3797                            &mut management_token,
3798                        )
3799                        .await;
3800                        if handle.service_counter_persistence().await.is_err() {
3801                            return;
3802                        }
3803                    }
3804                    chat.engine.tick(now_ms);
3805                    service_chat_tickets(
3806                        &mut chat,
3807                        &mut in_flight_chat,
3808                        &mut chat_pipeline_ready,
3809                        &chat_events,
3810                        pending_chat_transmissions.len(),
3811                        now_ms,
3812                    );
3813                    let drain = chat.drain();
3814                    let transmissions = drain.transmissions.clone();
3815                    publish_chat_drain(drain, &chat_events);
3816                    if !transmissions.is_empty() || !pending_chat_transmissions.is_empty() {
3817                        let sent = queue_chat_transmissions(
3818                            &node,
3819                            transmissions,
3820                            &mut pending_chat_transmissions,
3821                            &mut in_flight_chat,
3822                            &chat_pipeline_ready,
3823                            &channel_registry,
3824                            &mut chat,
3825                            now_ms,
3826                        )
3827                        .await;
3828                        publish_chat_drain(chat.drain(), &chat_events);
3829                        if sent > 0 && handle.service_counter_persistence().await.is_err() {
3830                            return;
3831                        }
3832                    }
3833                }
3834            }
3835        }
3836    };
3837
3838    // Either loop ending (pump error, shutdown command, fatal persistence
3839    // failure) ends the session.
3840    tokio::select! {
3841        _ = pump_loop => {}
3842        _ = command_loop => {}
3843    }
3844}
3845
3846async fn queue_chat_transmissions<M: MacBackend>(
3847    node: &LocalNode<M>,
3848    transmissions: Vec<umsh_text::engine::Transmission>,
3849    pending: &mut VecDeque<umsh_text::engine::Transmission>,
3850    in_flight: &mut Vec<InFlightChatTransmission>,
3851    pipeline_ready: &BTreeSet<[u8; 32]>,
3852    channels: &Rc<RefCell<ChannelRegistry>>,
3853    chat: &mut MobileChatState,
3854    now_ms: u64,
3855) -> usize {
3856    pending.extend(transmissions);
3857    // Keep a bounded pipeline aligned with the device's target-selected
3858    // TX queue. The durable pending queue below handles messages larger than
3859    // this window without imposing the mobile RAM choice on embedded MACs.
3860    if in_flight.len() >= MOBILE_CHAT_TRANSMIT_WINDOW {
3861        return 0;
3862    }
3863    let mut queued = 0;
3864    while let Some(transmission) = pending.pop_front() {
3865        let gate_peer = match transmission.destination {
3866            Destination::Peer(peer) => Some(peer),
3867            // Multicast is unaddressed and blind unicast carries no ACK, so
3868            // neither has a peer whose pipeline could be confirmed.
3869            Destination::Channel(_) | Destination::ChannelPeer { .. } => None,
3870        };
3871        if let Some(peer) = gate_peer {
3872            if !pipeline_ready.contains(&peer.0)
3873                && in_flight.iter().any(|entry| entry.gate_peer == Some(peer))
3874            {
3875                // First contact may require counter synchronization. Confirm
3876                // one authenticated frame before opening this peer's full
3877                // pipeline.
3878                pending.push_front(transmission);
3879                break;
3880            }
3881        }
3882        let mut payload = Vec::with_capacity(transmission.payload.len() + 1);
3883        payload.push(PayloadType::TextMessage as u8);
3884        payload.extend_from_slice(transmission.payload.as_slice());
3885        let sent = match transmission.destination {
3886            Destination::Peer(peer) => match node.peer(peer).await {
3887                Ok(connection) => {
3888                    connection
3889                        .send(&payload, &SendOptions::default().with_ack_requested(true))
3890                        .await
3891                }
3892                Err(_) => {
3893                    chat.engine.transmit_update(
3894                        transmission.transmission_id,
3895                        DeliveryState::Failed,
3896                        now_ms,
3897                    );
3898                    continue;
3899                }
3900            },
3901            Destination::Channel(channel) => {
3902                let Some(bound) = bound_channel(node, channels, &channel) else {
3903                    chat.engine.transmit_update(
3904                        transmission.transmission_id,
3905                        DeliveryState::Failed,
3906                        now_ms,
3907                    );
3908                    continue;
3909                };
3910                // Carry the full source address: a member who misses a
3911                // fragment can only ask us to resend it if our frames name
3912                // the key to address that request to.
3913                let mut options = SendOptions::default().with_full_source();
3914                // An emergency message that only channel members can read is
3915                // not an emergency message. The spec forbids encrypting chat
3916                // on `EMERGENCY` so anyone in range can act on it, whether or
3917                // not they hold the key; the full source key it already
3918                // carries is what keeps it attributable without it.
3919                if channel == crate::emergency_channel_tag() {
3920                    options = options.unencrypted();
3921                }
3922                bound.send_all(&payload, &options).await
3923            }
3924            Destination::ChannelPeer { channel, peer } => {
3925                let Some(bound) = bound_channel(node, channels, &channel) else {
3926                    chat.engine.transmit_update(
3927                        transmission.transmission_id,
3928                        DeliveryState::Failed,
3929                        now_ms,
3930                    );
3931                    continue;
3932                };
3933                // The MAC will only address a registered peer, and a channel
3934                // member is not one — nothing about being in a channel
3935                // together registers anybody. Register on the way out rather
3936                // than on sight: only the members we actually have to ask
3937                // something of spend a peer slot, and this is the only place
3938                // we ever ask.
3939                if node.peer(peer).await.is_err() {
3940                    chat.engine.transmit_update(
3941                        transmission.transmission_id,
3942                        DeliveryState::Failed,
3943                        now_ms,
3944                    );
3945                    continue;
3946                }
3947                // A repair request; the engine owns retrying it, so no ACK is
3948                // asked for here.
3949                let mut options = SendOptions::default().with_full_source();
3950                // Repairs carry the same message the multicast did, so they
3951                // are held to the same rule — and have to be, since the
3952                // receiving side refuses encrypted emergency text whatever
3953                // family it arrives in. It stays blind unicast even so: what
3954                // an unencrypted blind unicast still carries over a plain one
3955                // is the channel it names, which is what a repeater decides
3956                // to forward on.
3957                if channel == crate::emergency_channel_tag() {
3958                    options = options.unencrypted();
3959                }
3960                let r = bound.send(&peer, &payload, &options).await;
3961                r
3962            }
3963        };
3964        let ticket = match sent {
3965            Ok(ticket) => ticket,
3966            Err(_) => {
3967                // With a registered peer and an engine-bounded payload, the
3968                // expected failure here is temporary MAC queue / pending-ACK
3969                // capacity. Preserve ordering and retry after tickets advance.
3970                pending.push_front(transmission);
3971                break;
3972            }
3973        };
3974        in_flight.push(InFlightChatTransmission {
3975            transmission_id: transmission.transmission_id,
3976            gate_peer,
3977            ticket,
3978            sent_reported: false,
3979            non_ack: gate_peer.is_none(),
3980            queued_at_ms: now_ms,
3981            stall_reported: false,
3982        });
3983        queued += 1;
3984        if in_flight.len() >= MOBILE_CHAT_TRANSMIT_WINDOW {
3985            break;
3986        }
3987    }
3988    queued
3989}
3990
3991/// Solicit one channel member's identity over the channel they were heard on.
3992///
3993/// The request is a multicast every member receives but only the filtered hint
3994/// answers. Routing follows the evidence that member's own frames left: their
3995/// trace route if one was observed, otherwise a flood budget no larger than
3996/// the distance they were last heard from.
3997async fn request_identity_over_channel<M: MacBackend>(
3998    node: &LocalNode<M>,
3999    channels: &Rc<RefCell<ChannelRegistry>>,
4000    channel: ChannelTag,
4001    hint: NodeHint,
4002    nonce: u32,
4003    route: Option<MemberRoute>,
4004) -> Result<(), MobileMeshError> {
4005    let Some(bound) = bound_channel(node, channels, &channel) else {
4006        return Err(MobileMeshError::UnknownConversation);
4007    };
4008    let options_block = umsh_node::mac_command::IdentityRequestBuilder::new()
4009        .nonce(nonce)
4010        .and_then(|builder| builder.filter_hint(&hint))
4011        .map_err(|_| MobileMeshError::SendFailed)?
4012        .build();
4013    let cmd = umsh_node::MacCommand::IdentityRequest {
4014        options: &options_block,
4015    };
4016    let mut frame = [0u8; 128];
4017    frame[0] = PayloadType::MacCommand as u8;
4018    let length = umsh_node::mac_command::encode(&cmd, &mut frame[1..])
4019        .map_err(|_| MobileMeshError::SendFailed)?
4020        + 1;
4021    // Full source so the member can answer with a targeted unicast rather
4022    // than another multicast.
4023    let mut options = SendOptions::default().with_full_source();
4024    match route.as_ref() {
4025        Some(route) if !route.route_hints.is_empty() => {
4026            let hops = route
4027                .route_hints
4028                .iter()
4029                .filter_map(|hint| <[u8; 2]>::try_from(hint.as_slice()).ok())
4030                .map(umsh_core::RouterHint)
4031                .collect::<Vec<_>>();
4032            // An over-long observed route is not a reason to fail the
4033            // request; fall back to flooding at the distance it implies.
4034            options = match options.try_with_source_route(&hops) {
4035                Ok(options) => options,
4036                Err(_) => SendOptions::default()
4037                    .with_full_source()
4038                    .with_flood_hops(flood_budget(route.hop_count)),
4039            };
4040        }
4041        Some(MemberRoute {
4042            hop_count: Some(hops),
4043            ..
4044        }) => {
4045            options = options.with_flood_hops(flood_budget(Some(*hops)));
4046        }
4047        _ => {}
4048    }
4049    bound
4050        .send_all(&frame[..length], &options)
4051        .await
4052        .map(|_| ())
4053        .map_err(|_| MobileMeshError::SendFailed)
4054}
4055
4056/// How long one page of a Peer Repeaters listing is waited for.
4057///
4058/// A repeater builds its answer from tables it already holds, so the wait is
4059/// the mesh crossing and the responder's own channel-access window, not any
4060/// work on its part.
4061const PEER_REPEATERS_PAGE_TIMEOUT: Duration = Duration::from_secs(30);
4062
4063/// The most pages one listing is followed across.
4064///
4065/// A responder's table is bounded, so an enumeration that keeps handing back
4066/// cursors is a responder that has lost its place; the ask ends rather than
4067/// following it forever.
4068const PEER_REPEATERS_MAX_PAGES: usize = 8;
4069
4070/// The most time one listing is followed for, across all its pages.
4071///
4072/// The worker loop serves every command in turn, so a walk that kept waiting
4073/// out page timeouts back to back would hold the whole session hostage. Pages
4074/// from a live responder arrive in seconds; a walk this old is being dripped
4075/// at, and ends with what it has.
4076const PEER_REPEATERS_WALK_TIMEOUT: Duration = Duration::from_secs(60);
4077
4078/// Ask one repeater for its peer-repeater listing, following cursors until
4079/// the answer is complete.
4080///
4081/// Each page carries its own nonce, so a late page from an abandoned ask
4082/// cannot be mistaken for the one being waited on.
4083async fn collect_peer_repeaters<M: MacBackend>(
4084    node: &LocalNode<M>,
4085    handle: &M,
4086    peer: PublicKey,
4087) -> Result<Vec<MobileMeshPeerRepeaterRecord>, MobileMeshError> {
4088    let connection = node
4089        .peer(peer)
4090        .await
4091        .map_err(|_| MobileMeshError::InvalidPeer)?;
4092
4093    let pages: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
4094    let _subscription = {
4095        let pages = pages.clone();
4096        node.on_mac_command(move |from, command| {
4097            if from != peer {
4098                return;
4099            }
4100            if let umsh_node::OwnedMacCommand::PeerRepeatersResponse { body } = command {
4101                pages.borrow_mut().push(body.clone());
4102            }
4103        })
4104    };
4105
4106    let mut listing = Vec::new();
4107    let mut cursor: Option<Vec<u8>> = None;
4108    let walk_deadline = tokio::time::Instant::now() + PEER_REPEATERS_WALK_TIMEOUT;
4109    for _ in 0..PEER_REPEATERS_MAX_PAGES {
4110        let mut nonce_bytes = [0u8; 2];
4111        handle.fill_random(&mut nonce_bytes).await;
4112        let nonce = u16::from_be_bytes(nonce_bytes);
4113        pages.borrow_mut().clear();
4114        let sent = connection
4115            .request_peer_repeaters(nonce, cursor.as_deref(), &SendOptions::default())
4116            .await;
4117        if sent.is_err() {
4118            if listing.is_empty() {
4119                return Err(MobileMeshError::SendFailed);
4120            }
4121            // A follow-up ask that cannot leave ends the walk the same way
4122            // an unanswered one does: with the pages already in hand.
4123            break;
4124        }
4125
4126        let deadline =
4127            (tokio::time::Instant::now() + PEER_REPEATERS_PAGE_TIMEOUT).min(walk_deadline);
4128        let page = loop {
4129            let matched = pages.borrow_mut().iter().position(|body| {
4130                umsh_node::mac_command::PeerRepeatersResponseView::new(body).nonce() == Some(nonce)
4131            });
4132            if let Some(index) = matched {
4133                break Some(pages.borrow_mut().remove(index));
4134            }
4135            if tokio::time::Instant::now() >= deadline {
4136                break None;
4137            }
4138            // The worker's pump runs as a sibling future, so yielding here is
4139            // what lets the answer arrive at all.
4140            tokio::time::sleep(Duration::from_millis(20)).await;
4141        };
4142        let Some(page) = page else {
4143            // A listing that stopped part way is still what the repeater
4144            // said; the caller gets it rather than nothing.
4145            break;
4146        };
4147
4148        let view = umsh_node::mac_command::PeerRepeatersResponseView::new(&page);
4149        listing.extend(view.entries().map(peer_repeater_record));
4150        match view.cursor() {
4151            Some(next) => cursor = Some(next.to_vec()),
4152            None => break,
4153        }
4154        if tokio::time::Instant::now() >= walk_deadline {
4155            // No answer to the next ask would be waited for, so it is not
4156            // worth the airtime.
4157            break;
4158        }
4159    }
4160    Ok(listing)
4161}
4162
4163fn peer_repeater_record(
4164    entry: umsh_node::mac_command::PeerRepeaterEntryView<'_>,
4165) -> MobileMeshPeerRepeaterRecord {
4166    let signal = entry.rssi_snr();
4167    MobileMeshPeerRepeaterRecord {
4168        hint: entry.hint().map(Vec::from).unwrap_or_default(),
4169        name: entry.name().map(String::from),
4170        rssi_dbm: signal.map(|(rssi, _)| rssi),
4171        snr_quarter_db: signal.map(|(_, snr)| snr.as_quarter_db_steps()),
4172        last_heard_minutes: entry.last_heard_min(),
4173        location: entry
4174            .location()
4175            .filter(|location| !location.is_unspecified())
4176            .map(|location| location.as_bytes().to_vec()),
4177        region_codes: entry.regions().map(Vec::from).collect(),
4178    }
4179}
4180
4181/// Bind the channel a transmission names, so it can be sent over.
4182fn bound_channel<M: MacBackend>(
4183    node: &LocalNode<M>,
4184    channels: &Rc<RefCell<ChannelRegistry>>,
4185    channel: &ChannelTag,
4186) -> Option<umsh_node::BoundChannel<M>> {
4187    let key = channels.borrow().key(channel)?;
4188    // Looked up per send rather than cached: leaving and rejoining a channel
4189    // invalidates the binding, and the registry is the one place that knows.
4190    node.bound_channel(&umsh_node::Channel::private(key, ""))
4191}
4192
4193fn service_chat_tickets(
4194    chat: &mut MobileChatState,
4195    in_flight: &mut Vec<InFlightChatTransmission>,
4196    pipeline_ready: &mut BTreeSet<[u8; 32]>,
4197    events: &NotifyingSender<MobileChatWorkerEvent>,
4198    pending_depth: usize,
4199    now_ms: u64,
4200) {
4201    let occupied = in_flight.len();
4202    let mut index = 0;
4203    while index < in_flight.len() {
4204        let entry = &mut in_flight[index];
4205        // Checked before the state transitions below, so an entry that is
4206        // about to retire this pass is not accused on its way out.
4207        if !entry.stall_reported
4208            && !entry.ticket.was_transmitted()
4209            && now_ms.saturating_sub(entry.queued_at_ms) >= CHAT_TRANSMISSION_STALL_MS
4210        {
4211            entry.stall_reported = true;
4212            let waited = now_ms.saturating_sub(entry.queued_at_ms) / 1000;
4213            let transmission_id = entry.transmission_id;
4214            let _ = events.send(MobileChatWorkerEvent::Diagnostic(format!(
4215                "transmission {transmission_id} has not left the radio after {waited}s \
4216                 ({occupied}/{MOBILE_CHAT_TRANSMIT_WINDOW} window slots used, \
4217                 {pending_depth} more waiting)"
4218            )));
4219        }
4220        let entry = &mut in_flight[index];
4221        if entry.ticket.was_transmitted() && !entry.sent_reported {
4222            chat.engine
4223                .transmit_update(entry.transmission_id, DeliveryState::Sent, now_ms);
4224            entry.sent_reported = true;
4225        }
4226        if entry.non_ack && entry.sent_reported {
4227            // Nothing further can happen to this one: no acknowledgement is
4228            // coming, so transmission is where it ends. Retiring it here is
4229            // what keeps channel sends from accumulating in flight forever.
4230            in_flight.swap_remove(index);
4231        } else if entry.ticket.was_acked() {
4232            if let Some(peer) = entry.gate_peer {
4233                pipeline_ready.insert(peer.0);
4234            }
4235            chat.engine
4236                .transmit_update(entry.transmission_id, DeliveryState::Acked, now_ms);
4237            in_flight.swap_remove(index);
4238        } else if entry.ticket.has_failed() {
4239            chat.engine
4240                .transmit_update(entry.transmission_id, DeliveryState::Failed, now_ms);
4241            in_flight.swap_remove(index);
4242        } else {
4243            index += 1;
4244        }
4245    }
4246}
4247
4248fn publish_chat_drain(
4249    drain: crate::mobile_chat::ChatDrain,
4250    events: &NotifyingSender<MobileChatWorkerEvent>,
4251) {
4252    for mutation in drain.mutations {
4253        let _ = events.send(MobileChatWorkerEvent::Mutation(mutation));
4254    }
4255    for delivery in drain.deliveries {
4256        let _ = events.send(MobileChatWorkerEvent::Delivery(delivery));
4257    }
4258    for lookup in drain.lookups {
4259        let _ = events.send(MobileChatWorkerEvent::ArchiveLookup(lookup));
4260    }
4261    for resolution in drain.resolutions {
4262        let _ = events.send(MobileChatWorkerEvent::SenderResolution(resolution));
4263    }
4264    for diagnostic in drain.diagnostics {
4265        let _ = events.send(MobileChatWorkerEvent::Diagnostic(diagnostic));
4266    }
4267}
4268
4269/// Attach a frame's radio metadata to the records it produced.
4270///
4271/// The engine is transport-agnostic, so this is the only place the two are
4272/// together. Only records describing received content carry it — an outbound
4273/// echo or a placeholder has no frame behind it.
4274fn attach_rx_metadata(mutations: &mut [MobileChatMutationRecord], rx: &MobileChatRxMetadataRecord) {
4275    for mutation in mutations {
4276        let describes_receipt = match mutation.kind {
4277            MobileChatMutationKind::Insert => {
4278                mutation.direction == Some(MobileChatDirection::Inbound)
4279                    && mutation.presence == MobileChatPresence::Present
4280            }
4281            MobileChatMutationKind::UpdateBody => true,
4282            MobileChatMutationKind::Edit | MobileChatMutationKind::Delete => false,
4283        };
4284        if describes_receipt {
4285            mutation.rx = Some(rx.clone());
4286        }
4287    }
4288}
4289
4290/// Remember how a channel member was last reached, so a later request to them
4291/// can be routed by evidence instead of by a default flood budget.
4292fn remember_member_route(
4293    routes: &mut BTreeMap<(ChannelTag, [u8; 3]), MemberRoute>,
4294    channel: ChannelTag,
4295    hint: NodeHint,
4296    rx: &MobileChatRxMetadataRecord,
4297) {
4298    routes.insert(
4299        (channel, hint.0),
4300        MemberRoute {
4301            hop_count: rx.hop_count,
4302            route_hints: rx.route_hints.clone(),
4303        },
4304    );
4305}
4306
4307/// The flood budget an observed hop count implies. A hop count includes the
4308/// final link into this device, which no repeater has to pay for, so the
4309/// budget is one less than the distance the frame was heard from — and at
4310/// least one, since a budget of zero forwards nowhere.
4311fn flood_budget(hop_count: Option<u8>) -> u8 {
4312    hop_count
4313        .map(|hops| hops.saturating_sub(1))
4314        .unwrap_or(5)
4315        .max(1)
4316}
4317
4318/// What the last frame from a channel member showed about reaching them.
4319#[derive(Clone)]
4320struct MemberRoute {
4321    hop_count: Option<u8>,
4322    route_hints: Vec<Vec<u8>>,
4323}
4324
4325fn decode_peer(address: &str) -> Result<PublicKey, MobileError> {
4326    let bytes = umsh_core::base58::decode(address.as_bytes())?;
4327    Ok(PublicKey(bytes))
4328}
4329
4330fn decode_channel_keys(keys: Vec<Vec<u8>>) -> Result<Vec<ChannelKey>, MobileMeshError> {
4331    keys.into_iter()
4332        .map(|key| {
4333            <[u8; 32]>::try_from(key.as_slice())
4334                .map(ChannelKey)
4335                .map_err(|_| MobileMeshError::InvalidChannelKey)
4336        })
4337        .collect()
4338}
4339
4340/// The canonical fixed-width Base58 rendering of a peer key, matching what
4341/// `decode_peer` accepts and what the platform stores as an address.
4342fn encode_peer_address(peer: &PublicKey) -> String {
4343    umsh_core::base58::encode(&peer.0)
4344        .into_iter()
4345        .map(char::from)
4346        .collect()
4347}
4348
4349fn emit_ping_failure(events: &NotifyingSender<MobileMeshPingEventRecord>, operation_id: u64) {
4350    let _ = events.send(MobileMeshPingEventRecord {
4351        operation_id,
4352        outcome: MobileMeshPingOutcome::Failed,
4353        round_trip_milliseconds: None,
4354        hop_count: None,
4355        route_hints: Vec::new(),
4356        rssi_dbm: None,
4357        snr_centibels: None,
4358        lqi: None,
4359    });
4360}
4361
4362#[cfg(test)]
4363mod tests {
4364    use super::*;
4365    use crate::MobileChatDeliveryState;
4366    use std::time::Instant;
4367    use umsh_crypto::NodeIdentity;
4368
4369    fn identity(seed: u8) -> Arc<MobileIdentity> {
4370        let identity = SoftwareIdentity::from_secret_bytes(&[seed; 32]);
4371        let public_identity = crate::public_identity_record(identity.public_key());
4372        Arc::new(MobileIdentity {
4373            identity: Mutex::new(Some(identity)),
4374            public_identity,
4375        })
4376    }
4377
4378    fn address(identity: &MobileIdentity) -> String {
4379        identity.public_identity.canonical_address.clone()
4380    }
4381
4382    // ─── Reading a device whole, across the mesh ─────────────────────────
4383
4384    fn is_reply(property: u32, value: &[u8]) -> Vec<u8> {
4385        let mut buf = vec![0u8; 512];
4386        let len = frame::prop_is(&mut buf, 0, property, value).unwrap();
4387        buf.truncate(len);
4388        buf
4389    }
4390
4391    /// A `CMD_PROP_ARE` answering the first `answered` of `keys` with an
4392    /// empty value each, as a device that ran out of room would.
4393    fn are_reply(keys: &[u32]) -> Vec<u8> {
4394        let mut buf = vec![0u8; 512];
4395        let mut writer = frame::prop_are(&mut buf, 0).unwrap();
4396        for key in keys {
4397            writer.write_entry(*key, &[]).unwrap();
4398        }
4399        let len = writer.finish();
4400        buf.truncate(len);
4401        buf
4402    }
4403
4404    /// Ask what the outstanding request was, without decoding the frame.
4405    fn asked(crawl: &FetchCrawl) -> Vec<u32> {
4406        crawl.asked.clone()
4407    }
4408
4409    /// A list long enough to need more than one batch.
4410    fn long_list() -> Vec<u32> {
4411        vec![
4412            prop::PHY_ENABLED,
4413            prop::PHY_FREQ,
4414            prop::PHY_TX_POWER,
4415            prop::PHY_LORA_BW,
4416            prop::PHY_LORA_SF,
4417            prop::PHY_LORA_CR,
4418            prop::PHY_DUTY_NOW,
4419            prop::PHY_DUTY_LIMIT,
4420            prop::DEV_NAME,
4421            prop::DEV_DISCOVERABLE,
4422        ]
4423    }
4424
4425    #[test]
4426    fn a_fetch_asks_for_what_it_was_given_and_nothing_else() {
4427        let mut crawl = FetchCrawl::new(long_list(), true);
4428        crawl.next_request().unwrap().unwrap();
4429        assert_eq!(asked(&crawl).len(), SYNC_BATCH);
4430        assert_eq!(asked(&crawl), long_list()[..SYNC_BATCH].to_vec());
4431
4432        // The phone's own relationship with a radio is not an
4433        // administrator's business, and asking would spend airtime on a
4434        // refusal — so those keys never reach the queue at all.
4435        let filtered = FetchCrawl::new(
4436            vec![prop::DEV_NAME, prop::HOST_KEY, prop::MAC_PROMISCUOUS],
4437            true,
4438        );
4439        assert_eq!(filtered.pending, vec![prop::DEV_NAME]);
4440    }
4441
4442    #[test]
4443    fn a_fetch_asks_again_for_what_a_short_answer_left_out() {
4444        let mut crawl = FetchCrawl::new(long_list(), true);
4445        crawl.next_request().unwrap().unwrap();
4446        let batch = asked(&crawl);
4447        assert!(batch.len() > 1);
4448
4449        // The device stopped before its answer overflowed.
4450        crawl.receive(&are_reply(&batch[..2])).unwrap();
4451        assert_eq!(
4452            crawl
4453                .pending
4454                .iter()
4455                .take(batch.len() - 2)
4456                .copied()
4457                .collect::<Vec<_>>(),
4458            batch[2..].to_vec(),
4459            "the unanswered keys go back to the front of the queue"
4460        );
4461        assert_eq!(crawl.answers.len(), 2);
4462    }
4463
4464    #[test]
4465    fn a_fetch_falls_back_to_one_property_at_a_time() {
4466        let mut crawl = FetchCrawl::new(long_list(), true);
4467        crawl.next_request().unwrap().unwrap();
4468        let batch = asked(&crawl);
4469
4470        // A device that declines the command itself, whatever the hint
4471        // said.
4472        crawl
4473            .receive(&is_reply(
4474                prop::LAST_STATUS,
4475                &[umsh_ulcp::Status::UNIMPLEMENTED.0 as u8],
4476            ))
4477            .unwrap();
4478        assert!(!crawl.multi);
4479        assert_eq!(
4480            crawl
4481                .pending
4482                .iter()
4483                .take(batch.len())
4484                .copied()
4485                .collect::<Vec<_>>(),
4486            batch
4487        );
4488
4489        crawl.next_request().unwrap().unwrap();
4490        assert_eq!(asked(&crawl), vec![batch[0]]);
4491
4492        // A property it will not report comes back as a refusal rather
4493        // than as nothing: the caller has a screen to draw either way,
4494        // and "would not say" is not "never asked".
4495        crawl
4496            .receive(&is_reply(
4497                prop::LAST_STATUS,
4498                &[umsh_ulcp::Status::PROP_NOT_FOUND.0 as u8],
4499            ))
4500            .unwrap();
4501        assert_eq!(crawl.answers.len(), 1);
4502        assert_eq!(crawl.answers[0].property_id, batch[0]);
4503        assert!(crawl.answers[0].value.is_none());
4504        assert_eq!(
4505            crawl.answers[0].status_code,
4506            Some(umsh_ulcp::Status::PROP_NOT_FOUND.0)
4507        );
4508        assert!(!crawl.pending.contains(&batch[0]));
4509    }
4510
4511    /// The identity card: capabilities, firmware version, name and model
4512    /// in a single exchange, which is what every later screen is planned
4513    /// against and what caching it saves on every reopen.
4514    #[test]
4515    fn the_identity_card_is_one_exchange() {
4516        let card = crate::ulcp::ulcp_card_properties();
4517        let mut crawl = FetchCrawl::new(card.clone(), true);
4518        crawl.next_request().unwrap().unwrap();
4519        assert_eq!(asked(&crawl), card);
4520
4521        crawl.receive(&are_reply(&card)).unwrap();
4522        assert!(crawl.pending.is_empty(), "one exchange covered the card");
4523        assert_eq!(crawl.answers.len(), 4);
4524        assert!(crawl.next_request().unwrap().is_none());
4525    }
4526
4527    #[tokio::test]
4528    async fn the_phones_node_key_is_what_a_device_lists() {
4529        let directory = tempfile::tempdir().unwrap();
4530        let phone_identity = identity(21);
4531        let store = MobileCounterStore::new(directory.path().display().to_string()).unwrap();
4532        let phone = MobileMeshSession::new(phone_identity.clone(), store)
4533            .await
4534            .unwrap();
4535        assert_eq!(
4536            phone.node_public_key(),
4537            decode_peer(&address(&phone_identity)).unwrap().0.to_vec()
4538        );
4539    }
4540
4541    #[tokio::test]
4542    async fn one_device_is_managed_at_a_time() {
4543        let directory = tempfile::tempdir().unwrap();
4544        let phone_identity = identity(22);
4545        let device = address(&identity(23));
4546        let store = MobileCounterStore::new(directory.path().display().to_string()).unwrap();
4547        let phone = MobileMeshSession::new(phone_identity, store).await.unwrap();
4548
4549        let first = phone
4550            .begin_management_get(device.clone(), prop::DEV_NAME)
4551            .unwrap();
4552        let second = phone
4553            .begin_management_fetch(device.clone(), vec![prop::CAPS], true)
4554            .unwrap();
4555        assert_ne!(first, second);
4556
4557        // The first operation is on the air and will not be answered here;
4558        // the second is refused outright rather than queued behind it.
4559        let deadline = Instant::now() + Duration::from_secs(5);
4560        let refusal = loop {
4561            assert!(
4562                Instant::now() < deadline,
4563                "no report for the second operation"
4564            );
4565            if let Some(event) = phone
4566                .poll_update()
4567                .management_events
4568                .into_iter()
4569                .find(|event| event.operation_id == second)
4570            {
4571                break event;
4572            }
4573        };
4574        assert_eq!(refusal.outcome, MobileMeshManagementOutcome::Failed);
4575        assert_eq!(refusal.peer_address, device);
4576    }
4577
4578    #[tokio::test]
4579    async fn a_request_larger_than_one_payload_is_refused_before_it_is_sent() {
4580        let directory = tempfile::tempdir().unwrap();
4581        let phone_identity = identity(24);
4582        let device = address(&identity(25));
4583        let store = MobileCounterStore::new(directory.path().display().to_string()).unwrap();
4584        let phone = MobileMeshSession::new(phone_identity, store).await.unwrap();
4585
4586        assert_eq!(
4587            phone.begin_management_set(device.clone(), prop::DEV_NAME, vec![0x41; 400]),
4588            Err(MobileMeshError::InvalidRequest)
4589        );
4590        assert_eq!(
4591            phone.begin_management_get_many(device, Vec::new()),
4592            Err(MobileMeshError::InvalidRequest)
4593        );
4594    }
4595
4596    /// The 3-byte hint a node's multicast frames claim, which is the leading
4597    /// bytes of its public key.
4598    fn hint_of(identity: &MobileIdentity) -> Vec<u8> {
4599        decode_peer(&address(identity)).unwrap().0[..3].to_vec()
4600    }
4601
4602    async fn channel_session(name: &str) -> Arc<MobileMeshSession> {
4603        let directory = tempfile::tempdir().unwrap();
4604        let store =
4605            MobileCounterStore::new(directory.path().join(name).display().to_string()).unwrap();
4606        // The temp directory must outlive the session's counter store.
4607        std::mem::forget(directory);
4608        MobileMeshSession::new(identity(31), store).await.unwrap()
4609    }
4610
4611    #[tokio::test]
4612    async fn channel_registration_is_idempotent_and_reversible() {
4613        let session = channel_session("channels").await;
4614        let key = vec![0x5au8; 32];
4615
4616        session.register_channels(vec![key.clone()]).await.unwrap();
4617        // Re-registering an already-joined channel restates the current
4618        // state, which is what a session-start replay does.
4619        session.register_channels(vec![key.clone()]).await.unwrap();
4620        session.remove_channels(vec![key.clone()]).await.unwrap();
4621        // Leaving a channel that is not joined is likewise not an error.
4622        session.remove_channels(vec![key.clone()]).await.unwrap();
4623        // And the key can come back afterwards.
4624        session.register_channels(vec![key]).await.unwrap();
4625    }
4626
4627    #[tokio::test]
4628    async fn channel_keys_must_be_full_length() {
4629        let session = channel_session("shortkey").await;
4630        assert_eq!(
4631            session.register_channels(vec![vec![0x01; 31]]).await,
4632            Err(MobileMeshError::InvalidChannelKey)
4633        );
4634        assert_eq!(
4635            session.remove_channels(vec![Vec::new()]).await,
4636            Err(MobileMeshError::InvalidChannelKey)
4637        );
4638    }
4639
4640    #[tokio::test]
4641    async fn the_phone_mac_holds_more_channels_than_the_embedded_default() {
4642        let session = channel_session("capacity").await;
4643        // Distinct keys, one per slot the phone advertises.
4644        let keys: Vec<Vec<u8>> = (0..MOBILE_MAC_CHANNELS)
4645            .map(|index| {
4646                let mut key = vec![0u8; 32];
4647                key[0] = index as u8;
4648                key[1] = 0xA5;
4649                key
4650            })
4651            .collect();
4652        assert!(keys.len() > umsh_mac::DEFAULT_CHANNELS);
4653        session.register_channels(keys).await.unwrap();
4654
4655        let overflow = vec![vec![0xFFu8; 32]];
4656        assert_eq!(
4657            session.register_channels(overflow).await,
4658            Err(MobileMeshError::ChannelCapacity)
4659        );
4660    }
4661
4662    #[tokio::test]
4663    async fn two_rust_sessions_complete_an_authenticated_ping() {
4664        let directory = tempfile::tempdir().unwrap();
4665        let alice_identity = identity(7);
4666        let bob_identity = identity(9);
4667        let alice_root = directory.path().join("alice");
4668        let bob_root = directory.path().join("bob");
4669        let alice_store = MobileCounterStore::new(alice_root.display().to_string()).unwrap();
4670        let bob_store = MobileCounterStore::new(bob_root.display().to_string()).unwrap();
4671        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
4672            .await
4673            .unwrap();
4674        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
4675            .await
4676            .unwrap();
4677        // Constructing or repeatedly rebooting a session is read-only. The
4678        // first reservation write must be caused by an actual authenticated
4679        // send, never by startup.
4680        assert!(!alice_root.exists());
4681        assert!(!bob_root.exists());
4682
4683        // Each endpoint knows the other peer, as it would from its durable peer
4684        // registry in the application. Starting both pings registers both keys
4685        // through the same public Rust API without test-only MAC access.
4686        let operation = alice.ping(address(&bob_identity), 2_000).unwrap();
4687        let _ = bob.ping(address(&alice_identity), 2_000).unwrap();
4688        let deadline = Instant::now() + Duration::from_secs(10);
4689        loop {
4690            let alice_update = alice.poll_update();
4691            for frame in alice_update.outbound_frames {
4692                assert!(
4693                    alice_root.exists(),
4694                    "Alice released a frame before persisting its reservation"
4695                );
4696                alice.complete_outbound_frame(frame.id, true).unwrap();
4697                bob.receive(MobileMeshRxRecord {
4698                    data: frame.data,
4699                    rssi_dbm: Some(-40),
4700                    lqi: None,
4701                    snr_cb: Some(100),
4702                })
4703                .unwrap();
4704            }
4705            if let Some(event) = alice_update.ping_events.into_iter().next() {
4706                assert_eq!(event.operation_id, operation);
4707                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
4708                assert!(event.round_trip_milliseconds.is_some());
4709                assert_eq!(event.hop_count, Some(1));
4710                assert!(event.route_hints.is_empty());
4711                assert_eq!(event.rssi_dbm, Some(-42));
4712                assert_eq!(event.snr_centibels, Some(90));
4713                assert_eq!(event.lqi, None);
4714                break;
4715            }
4716
4717            let bob_update = bob.poll_update();
4718            for frame in bob_update.outbound_frames {
4719                assert!(
4720                    bob_root.exists(),
4721                    "Bob released a frame before persisting its reservation"
4722                );
4723                bob.complete_outbound_frame(frame.id, true).unwrap();
4724                alice
4725                    .receive(MobileMeshRxRecord {
4726                        data: frame.data,
4727                        rssi_dbm: Some(-42),
4728                        lqi: None,
4729                        snr_cb: Some(90),
4730                    })
4731                    .unwrap();
4732            }
4733            assert!(Instant::now() < deadline, "ping did not complete");
4734            std::thread::sleep(Duration::from_millis(5));
4735        }
4736    }
4737
4738    /// Drive one authenticated ping between the two sessions to completion,
4739    /// shuttling frames both ways.
4740    async fn complete_ping(alice: &MobileMeshSession, bob: &MobileMeshSession, target: String) {
4741        let operation = alice.ping(target, 2_000).unwrap();
4742        let deadline = Instant::now() + Duration::from_secs(10);
4743        loop {
4744            let alice_update = alice.poll_update();
4745            for frame in alice_update.outbound_frames {
4746                alice.complete_outbound_frame(frame.id, true).unwrap();
4747                bob.receive(MobileMeshRxRecord {
4748                    data: frame.data,
4749                    rssi_dbm: Some(-40),
4750                    lqi: None,
4751                    snr_cb: Some(100),
4752                })
4753                .unwrap();
4754            }
4755            if let Some(event) = alice_update.ping_events.into_iter().next() {
4756                assert_eq!(event.operation_id, operation);
4757                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
4758                break;
4759            }
4760            let bob_update = bob.poll_update();
4761            for frame in bob_update.outbound_frames {
4762                bob.complete_outbound_frame(frame.id, true).unwrap();
4763                alice
4764                    .receive(MobileMeshRxRecord {
4765                        data: frame.data,
4766                        rssi_dbm: Some(-42),
4767                        lqi: None,
4768                        snr_cb: Some(90),
4769                    })
4770                    .unwrap();
4771            }
4772            assert!(Instant::now() < deadline, "ping did not complete");
4773            std::thread::sleep(Duration::from_millis(5));
4774        }
4775    }
4776
4777    #[tokio::test]
4778    async fn removed_peer_re_registers_cleanly_and_traffic_still_flows() {
4779        let directory = tempfile::tempdir().unwrap();
4780        let alice_identity = identity(21);
4781        let bob_identity = identity(23);
4782        let alice_store =
4783            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
4784        let bob_store =
4785            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
4786        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
4787            .await
4788            .unwrap();
4789        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
4790            .await
4791            .unwrap();
4792
4793        alice
4794            .register_peers(vec![address(&bob_identity)])
4795            .await
4796            .unwrap();
4797        bob.register_peers(vec![address(&alice_identity)])
4798            .await
4799            .unwrap();
4800        complete_ping(&alice, &bob, address(&bob_identity)).await;
4801
4802        // Removal is idempotent — an unknown peer and a double removal are
4803        // both fine — and must not disturb the session.
4804        alice
4805            .remove_peers(vec![address(&bob_identity)])
4806            .await
4807            .unwrap();
4808        alice
4809            .remove_peers(vec![address(&bob_identity)])
4810            .await
4811            .unwrap();
4812        alice
4813            .remove_peers(vec![address(&alice_identity)])
4814            .await
4815            .unwrap();
4816
4817        // Re-registering after removal starts from a clean slot; Bob's
4818        // replay state still accepts Alice because her TX counter is
4819        // identity-scoped and survived the peer-table churn.
4820        alice
4821            .register_peers(vec![address(&bob_identity)])
4822            .await
4823            .unwrap();
4824        complete_ping(&alice, &bob, address(&bob_identity)).await;
4825    }
4826
4827    /// The ask is addressed and authenticated: one repeater's own account of
4828    /// its neighborhood, not a question put to the mesh at large.
4829    #[tokio::test]
4830    async fn request_peer_repeaters_emits_a_unicast_addressed_to_the_peer() {
4831        let directory = tempfile::tempdir().unwrap();
4832        let alice_identity = identity(51);
4833        let bob_key = *SoftwareIdentity::from_secret_bytes(&[53; 32]).public_key();
4834        let alice_store =
4835            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
4836        let alice = MobileMeshSession::new(alice_identity, alice_store)
4837            .await
4838            .unwrap();
4839
4840        // The listing never arrives — no repeater is listening — so the ask
4841        // runs beside a loop watching for what it put on the air, and is
4842        // dropped once that has been seen.
4843        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
4844        let frame = tokio::select! {
4845            _ = alice.request_peer_repeaters(bob_key.0.to_vec()) => {
4846                panic!("the listing cannot complete with nobody to answer")
4847            }
4848            frame = async {
4849                loop {
4850                    let update = alice.poll_update();
4851                    if let Some(frame) = update.outbound_frames.into_iter().next() {
4852                        break frame;
4853                    }
4854                    assert!(tokio::time::Instant::now() < deadline, "no request went out");
4855                    tokio::time::sleep(Duration::from_millis(5)).await;
4856                }
4857            } => frame,
4858        };
4859
4860        // The payload is sealed to the peer, so the frame proves addressing
4861        // and nothing further from outside; that the body really is command
4862        // ten is what the two-node mesh test decrypts and answers.
4863        let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
4864        assert_eq!(header.packet_type(), umsh_core::PacketType::Unicast);
4865        assert_eq!(
4866            header.dst,
4867            Some(umsh_core::NodeHint::from_public_key(&bob_key))
4868        );
4869        alice.complete_outbound_frame(frame.id, true).unwrap();
4870
4871        // An unparseable key is refused before anything is sent.
4872        assert_eq!(
4873            alice.request_peer_repeaters(vec![0x01, 0x02]).await,
4874            Err(MobileMeshError::InvalidPeer)
4875        );
4876    }
4877
4878    #[tokio::test]
4879    async fn discover_identities_emits_one_acceptable_zero_hop_broadcast() {
4880        let directory = tempfile::tempdir().unwrap();
4881        let alice_identity = identity(31);
4882        let bob_identity = identity(33);
4883        let alice_store =
4884            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
4885        let bob_store =
4886            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
4887        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
4888            .await
4889            .unwrap();
4890        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
4891            .await
4892            .unwrap();
4893
4894        alice
4895            .discover_identities(None, Some(0x02), None, Vec::new())
4896            .await
4897            .unwrap();
4898
4899        let deadline = Instant::now() + Duration::from_secs(10);
4900        let frames = loop {
4901            let update = alice.poll_update();
4902            if !update.outbound_frames.is_empty() {
4903                break update.outbound_frames;
4904            }
4905            assert!(Instant::now() < deadline, "solicitation never went out");
4906            std::thread::sleep(Duration::from_millis(5));
4907        };
4908        // One broadcast, no retries, no companions.
4909        assert_eq!(frames.len(), 1);
4910        let frame = frames.into_iter().next().unwrap();
4911        alice.complete_outbound_frame(frame.id, true).unwrap();
4912        let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
4913        assert_eq!(header.packet_type(), umsh_core::PacketType::Broadcast);
4914        // Zero-hop: no flood budget for repeaters to spend.
4915        assert!(header.flood_hops.is_none());
4916        // Full source, so a stranger can unicast its identity back.
4917        assert!(header.fcf.full_source());
4918
4919        // A bystander session consumes the solicitation without error.
4920        // (Whether it answers is its responder's business — the full
4921        // reply loop is covered separately below.)
4922        bob.receive(MobileMeshRxRecord {
4923            data: frame.data,
4924            rssi_dbm: Some(-40),
4925            lqi: None,
4926            snr_cb: Some(100),
4927        })
4928        .unwrap();
4929
4930        // An unrestricted ask still satisfies the rule that a broadcast
4931        // request carries at least one filter: it gets a zero-bit
4932        // capability filter, which every node matches.
4933        alice
4934            .discover_identities(None, None, None, Vec::new())
4935            .await
4936            .unwrap();
4937        let deadline = Instant::now() + Duration::from_secs(10);
4938        let frames = loop {
4939            let update = alice.poll_update();
4940            if !update.outbound_frames.is_empty() {
4941                break update.outbound_frames;
4942            }
4943            assert!(Instant::now() < deadline, "solicitation never went out");
4944            std::thread::sleep(Duration::from_millis(5));
4945        };
4946        assert_eq!(frames.len(), 1);
4947        let frame = frames.into_iter().next().unwrap();
4948        alice.complete_outbound_frame(frame.id, true).unwrap();
4949        let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
4950        let body = &frame.data[header.body_range.clone()];
4951        assert_eq!(body[0], umsh_core::PayloadType::MacCommand as u8);
4952        let umsh_node::MacCommand::IdentityRequest { options } =
4953            umsh_node::mac_command::parse(&body[1..]).unwrap()
4954        else {
4955            panic!("expected an identity request");
4956        };
4957        let has_vacuous_caps_filter = umsh_core::options::OptionDecoder::new(options)
4958            .filter_map(Result::ok)
4959            .any(|(number, value)| {
4960                number == umsh_node::mac_command::identity_filter::FILTER_NODE_CAPS && value == [0]
4961            });
4962        assert!(has_vacuous_caps_filter);
4963
4964        // Steered at a remote vantage point and aimed at one router there by
4965        // its two-byte hint — the shape that identifies an intermediate hop.
4966        // The ask still goes out with no flood budget for a repeater to spend
4967        // on its own initiative.
4968        alice
4969            .discover_identities(
4970                None,
4971                None,
4972                Some(vec![0x5A, 0x5B]),
4973                vec![vec![0xAB, 0xCD], vec![0x12, 0x34]],
4974            )
4975            .await
4976            .unwrap();
4977        let deadline = Instant::now() + Duration::from_secs(10);
4978        let frames = loop {
4979            let update = alice.poll_update();
4980            if !update.outbound_frames.is_empty() {
4981                break update.outbound_frames;
4982            }
4983            assert!(
4984                Instant::now() < deadline,
4985                "routed solicitation never went out"
4986            );
4987            std::thread::sleep(Duration::from_millis(5));
4988        };
4989        assert_eq!(frames.len(), 1);
4990        let frame = frames.into_iter().next().unwrap();
4991        alice.complete_outbound_frame(frame.id, true).unwrap();
4992        let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
4993        assert_eq!(header.packet_type(), umsh_core::PacketType::Broadcast);
4994        // No FHOPS field, despite the route: try_with_source_route back-fills
4995        // a flood budget from the route length, and any budget at all makes
4996        // the far end drop an unhinted solicitation.
4997        assert!(header.flood_hops.is_none());
4998        assert!(header.fcf.full_source());
4999        let options =
5000            umsh_core::ParsedOptions::extract(&frame.data, header.options_range.clone()).unwrap();
5001        // The route we asked for, in send order.
5002        let route = options
5003            .source_route
5004            .clone()
5005            .map(|range| frame.data[range].to_vec())
5006            .expect("a steered request carries a Route option");
5007        assert_eq!(route, vec![0xAB, 0xCD, 0x12, 0x34]);
5008        // And an empty trace for the repeaters to fill, which is what gives
5009        // the answering strangers a path home.
5010        let trace = options
5011            .trace_route
5012            .clone()
5013            .map(|range| frame.data[range].to_vec())
5014            .expect("a steered request carries a trace route");
5015        assert!(trace.is_empty());
5016
5017        // The hint filter rides as the two bytes given, and the vacuous caps
5018        // filter is dropped: the ask is already narrowed to one node.
5019        let body = &frame.data[header.body_range.clone()];
5020        let umsh_node::MacCommand::IdentityRequest { options } =
5021            umsh_node::mac_command::parse(&body[1..]).unwrap()
5022        else {
5023            panic!("expected an identity request");
5024        };
5025        let filters: Vec<_> = umsh_core::options::OptionDecoder::new(options)
5026            .filter_map(Result::ok)
5027            .map(|(number, value)| (number, value.to_vec()))
5028            .collect();
5029        assert!(filters.contains(&(
5030            umsh_node::mac_command::identity_filter::FILTER_NODE_HINT,
5031            vec![0x5A, 0x5B]
5032        )));
5033        assert!(
5034            !filters
5035                .iter()
5036                .any(|(number, _)| *number
5037                    == umsh_node::mac_command::identity_filter::FILTER_NODE_CAPS),
5038            "a hint-filtered ask needs no vacuous capability filter"
5039        );
5040    }
5041
5042    /// The whole discover loop between two strangers: Alice's zero-hop
5043    /// broadcast ask reaches Bob, Bob's default-on responder answers with
5044    /// a jittered authenticated unicast carrying his full source key, and
5045    /// Alice — who has never registered Bob — auto-registers him
5046    /// transiently, verifies the reply, and surfaces it as an
5047    /// advertisement event. This is the exact path the Discover sheet
5048    /// rides on hardware.
5049    #[tokio::test]
5050    async fn discover_solicitation_earns_a_stranger_reply_end_to_end() {
5051        let directory = tempfile::tempdir().unwrap();
5052        let alice_identity = identity(21);
5053        let bob_identity = identity(23);
5054        let alice_store =
5055            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
5056        let bob_store =
5057            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
5058        // Bob's reply is held for a random slice of the 30-second identity
5059        // response window. Virtual time collapses that wait whenever his
5060        // worker is otherwise idle, so the deadline below bounds the shuttle
5061        // loop rather than the protocol delay.
5062        let alice = MobileMeshSession::new_with_virtual_time(alice_identity.clone(), alice_store)
5063            .await
5064            .unwrap();
5065        let bob = MobileMeshSession::new_with_virtual_time(bob_identity.clone(), bob_store)
5066            .await
5067            .unwrap();
5068        // Bob answers under a display name; Alice should hear it back.
5069        bob.set_discoverable(true, Some("Bob's phone".into()))
5070            .await
5071            .unwrap();
5072        // And under a shared location: the responder serves the same
5073        // description an advertisement carries.
5074        bob.set_advertised_location(Some(MobileMeshSharedLocationRecord {
5075            latitude_degrees: 48.1173,
5076            longitude_degrees: 11.5167,
5077            precision_bytes: 5,
5078        }))
5079        .await
5080        .unwrap();
5081
5082        alice
5083            .discover_identities(None, None, None, Vec::new())
5084            .await
5085            .unwrap();
5086
5087        // Shuttle frames both ways until Bob's identity lands at Alice.
5088        let bob_address = address(&bob_identity);
5089        let deadline = Instant::now() + Duration::from_secs(15);
5090        let event = 'outer: loop {
5091            let alice_update = alice.poll_update();
5092            for frame in alice_update.outbound_frames {
5093                alice.complete_outbound_frame(frame.id, true).unwrap();
5094                bob.receive(MobileMeshRxRecord {
5095                    data: frame.data,
5096                    rssi_dbm: Some(-40),
5097                    lqi: None,
5098                    snr_cb: Some(100),
5099                })
5100                .unwrap();
5101            }
5102            for event in alice_update.advertisement_events {
5103                if event.peer_address == bob_address {
5104                    break 'outer event;
5105                }
5106            }
5107            let bob_update = bob.poll_update();
5108            for frame in bob_update.outbound_frames {
5109                bob.complete_outbound_frame(frame.id, true).unwrap();
5110                alice
5111                    .receive(MobileMeshRxRecord {
5112                        data: frame.data,
5113                        rssi_dbm: Some(-42),
5114                        lqi: None,
5115                        snr_cb: Some(90),
5116                    })
5117                    .unwrap();
5118            }
5119            assert!(Instant::now() < deadline, "no identity reply reached Alice");
5120            std::thread::sleep(Duration::from_millis(5));
5121        };
5122        // The reply is a MAC-authenticated unicast, not a broadcast the
5123        // platform still has to signature-check.
5124        assert!(event.source_authenticated);
5125        let payload = umsh_node::NodeIdentityPayload::from_bytes(&event.payload).unwrap();
5126        assert_eq!(payload.name.as_deref(), Some("Bob's phone"));
5127        let cell = payload
5128            .location
5129            .expect("the reply serves the shared location");
5130        assert_eq!(cell.precision(), 5);
5131        let (lat, lon) = cell.center();
5132        assert!((f64::from(lat) - 48.1173).abs() < 0.01);
5133        assert!((f64::from(lon) - 11.5167).abs() < 0.01);
5134
5135        // Opting out is honored: a fresh ask earns silence from Bob.
5136        bob.set_discoverable(false, None).await.unwrap();
5137        alice
5138            .discover_identities(None, None, None, Vec::new())
5139            .await
5140            .unwrap();
5141        let quiet_until = Instant::now() + Duration::from_secs(6);
5142        while Instant::now() < quiet_until {
5143            let alice_update = alice.poll_update();
5144            for frame in alice_update.outbound_frames {
5145                alice.complete_outbound_frame(frame.id, true).unwrap();
5146                bob.receive(MobileMeshRxRecord {
5147                    data: frame.data,
5148                    rssi_dbm: Some(-40),
5149                    lqi: None,
5150                    snr_cb: Some(100),
5151                })
5152                .unwrap();
5153            }
5154            let bob_update = bob.poll_update();
5155            assert!(
5156                bob_update.outbound_frames.is_empty(),
5157                "Bob answered while not discoverable"
5158            );
5159            std::thread::sleep(Duration::from_millis(20));
5160        }
5161    }
5162
5163    #[tokio::test]
5164    async fn peer_route_is_visible_and_resettable() {
5165        let directory = tempfile::tempdir().unwrap();
5166        let alice_identity = identity(11);
5167        let bob_identity = identity(13);
5168        let alice_store =
5169            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
5170        let bob_store =
5171            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
5172        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
5173            .await
5174            .unwrap();
5175        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
5176            .await
5177            .unwrap();
5178
5179        // A peer nobody has heard from has no route, and inspecting it must
5180        // not register the peer or invent one.
5181        assert_eq!(
5182            alice.peer_route(address(&bob_identity)).await.unwrap(),
5183            MobileMeshRouteRecord::unknown()
5184        );
5185        assert!(
5186            !alice
5187                .clear_peer_route(address(&bob_identity))
5188                .await
5189                .unwrap()
5190        );
5191
5192        let operation = alice.ping(address(&bob_identity), 2_000).unwrap();
5193        let _ = bob.ping(address(&alice_identity), 2_000).unwrap();
5194        let deadline = Instant::now() + Duration::from_secs(10);
5195        loop {
5196            let alice_update = alice.poll_update();
5197            for frame in alice_update.outbound_frames {
5198                alice.complete_outbound_frame(frame.id, true).unwrap();
5199                bob.receive(MobileMeshRxRecord {
5200                    data: frame.data,
5201                    rssi_dbm: Some(-40),
5202                    lqi: None,
5203                    snr_cb: Some(100),
5204                })
5205                .unwrap();
5206            }
5207            if let Some(event) = alice_update.ping_events.into_iter().next() {
5208                assert_eq!(event.operation_id, operation);
5209                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
5210                break;
5211            }
5212
5213            let bob_update = bob.poll_update();
5214            for frame in bob_update.outbound_frames {
5215                bob.complete_outbound_frame(frame.id, true).unwrap();
5216                alice
5217                    .receive(MobileMeshRxRecord {
5218                        data: frame.data,
5219                        rssi_dbm: Some(-42),
5220                        lqi: None,
5221                        snr_cb: Some(90),
5222                    })
5223                    .unwrap();
5224            }
5225            assert!(Instant::now() < deadline, "ping did not complete");
5226            std::thread::sleep(Duration::from_millis(5));
5227        }
5228
5229        // The pong carried a trace route that accumulated no hints, because
5230        // there is no repeater between the two. That is a direct peer — not a
5231        // source route naming no routers, which would put an empty (and
5232        // meaningless) SourceRoute option on every packet alice sends back.
5233        let route = alice.peer_route(address(&bob_identity)).await.unwrap();
5234        assert_eq!(route.kind, MobileMeshRouteKind::Direct);
5235        assert!(route.hints.is_empty());
5236        assert_eq!(route.flood_hops, None);
5237
5238        // Resetting reports that a route was held, and leaves the peer with
5239        // nothing cached. A second reset has nothing left to discard.
5240        assert!(
5241            alice
5242                .clear_peer_route(address(&bob_identity))
5243                .await
5244                .unwrap()
5245        );
5246        assert_eq!(
5247            alice.peer_route(address(&bob_identity)).await.unwrap(),
5248            MobileMeshRouteRecord::unknown()
5249        );
5250        assert!(
5251            !alice
5252                .clear_peer_route(address(&bob_identity))
5253                .await
5254                .unwrap()
5255        );
5256
5257        // Clearing a route must not disturb the peer's crypto state: the next
5258        // ping still completes, and teaches the route again.
5259        let operation = alice.ping(address(&bob_identity), 2_000).unwrap();
5260        let deadline = Instant::now() + Duration::from_secs(10);
5261        loop {
5262            let alice_update = alice.poll_update();
5263            for frame in alice_update.outbound_frames {
5264                alice.complete_outbound_frame(frame.id, true).unwrap();
5265                bob.receive(MobileMeshRxRecord {
5266                    data: frame.data,
5267                    rssi_dbm: Some(-40),
5268                    lqi: None,
5269                    snr_cb: Some(100),
5270                })
5271                .unwrap();
5272            }
5273            if let Some(event) = alice_update.ping_events.into_iter().next() {
5274                assert_eq!(event.operation_id, operation);
5275                assert_eq!(event.outcome, MobileMeshPingOutcome::Reply);
5276                break;
5277            }
5278
5279            let bob_update = bob.poll_update();
5280            for frame in bob_update.outbound_frames {
5281                bob.complete_outbound_frame(frame.id, true).unwrap();
5282                alice
5283                    .receive(MobileMeshRxRecord {
5284                        data: frame.data,
5285                        rssi_dbm: Some(-42),
5286                        lqi: None,
5287                        snr_cb: Some(90),
5288                    })
5289                    .unwrap();
5290            }
5291            assert!(
5292                Instant::now() < deadline,
5293                "ping after reset did not complete"
5294            );
5295            std::thread::sleep(Duration::from_millis(5));
5296        }
5297        assert_eq!(
5298            alice.peer_route(address(&bob_identity)).await.unwrap().kind,
5299            MobileMeshRouteKind::Direct
5300        );
5301    }
5302
5303    #[tokio::test]
5304    async fn broadcast_advertisement_reaches_peer_with_valid_signature() {
5305        let directory = tempfile::tempdir().unwrap();
5306        let alice_identity = identity(21);
5307        let bob_identity = identity(23);
5308        let alice_store =
5309            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
5310        let bob_store =
5311            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
5312        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
5313            .await
5314            .unwrap();
5315        let bob = MobileMeshSession::new(bob_identity, bob_store)
5316            .await
5317            .unwrap();
5318
5319        // The signed bundle used for QR/URI sharing verifies out of band.
5320        let bundle = alice
5321            .sign_identity_bundle(Some("Alice's Phone".to_owned()), Some(1_760_000_000))
5322            .await
5323            .unwrap();
5324        let record = crate::decode_node_identity(address(&alice_identity), bundle.clone()).unwrap();
5325        assert_eq!(record.signature, crate::IdentitySignatureState::Valid);
5326        assert_eq!(record.name.as_deref(), Some("Alice's Phone"));
5327        assert_eq!(record.role_label, "Chat");
5328        let uri = crate::node_uri_with_identity(address(&alice_identity), bundle).unwrap();
5329        assert!(
5330            crate::inspect_node_uri(uri)
5331                .unwrap()
5332                .identity_payload
5333                .is_some()
5334        );
5335
5336        alice
5337            .advertise_identity(Some("Alice's Phone".to_owned()), None)
5338            .await
5339            .unwrap();
5340
5341        let deadline = Instant::now() + Duration::from_secs(10);
5342        loop {
5343            for frame in alice.poll_update().outbound_frames {
5344                alice.complete_outbound_frame(frame.id, true).unwrap();
5345                bob.receive(MobileMeshRxRecord {
5346                    data: frame.data,
5347                    rssi_dbm: Some(-50),
5348                    lqi: None,
5349                    snr_cb: None,
5350                })
5351                .unwrap();
5352            }
5353            let bob_update = bob.poll_update();
5354            // Presence is reported for the same frame, independently of what
5355            // it carried: this is the only signal a payload-free beacon
5356            // produces, so it must not be conditional on a payload.
5357            let heard = bob_update.peer_heard_events;
5358            if let Some(event) = bob_update.advertisement_events.into_iter().next() {
5359                assert_eq!(
5360                    heard.iter().find_map(|record| record.peer_address.clone()),
5361                    Some(address(&alice_identity)),
5362                    "the frame that carried the advertisement also reported presence"
5363                );
5364                assert_eq!(event.peer_address, address(&alice_identity));
5365                // A broadcast has no MIC, so the platform is told the sender
5366                // was not authenticated and must fall back to the bundle's
5367                // own signature — which is why one is attached.
5368                assert!(!event.source_authenticated);
5369                let received =
5370                    crate::decode_node_identity(event.peer_address, event.payload).unwrap();
5371                assert_eq!(received.signature, crate::IdentitySignatureState::Valid);
5372                assert_eq!(received.name.as_deref(), Some("Alice's Phone"));
5373                break;
5374            }
5375            assert!(Instant::now() < deadline, "advertisement not received");
5376            std::thread::sleep(Duration::from_millis(5));
5377        }
5378    }
5379
5380    /// The location policy in one pass: a shared cell rides every live
5381    /// advertisement, never the durable QR/URI bundle, and clearing it
5382    /// removes it from the next send rather than lingering.
5383    #[tokio::test]
5384    async fn a_shared_location_rides_adverts_but_never_the_durable_bundle() {
5385        let directory = tempfile::tempdir().unwrap();
5386        let alice_identity = identity(51);
5387        let bob_identity = identity(53);
5388        let alice_store =
5389            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
5390        let bob_store =
5391            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
5392        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
5393            .await
5394            .unwrap();
5395        let bob = MobileMeshSession::new(bob_identity, bob_store)
5396            .await
5397            .unwrap();
5398
5399        alice
5400            .set_advertised_location(Some(MobileMeshSharedLocationRecord {
5401                latitude_degrees: 37.774_929,
5402                longitude_degrees: -122.419_416,
5403                precision_bytes: 5,
5404            }))
5405            .await
5406            .unwrap();
5407
5408        // The durable bundle stays location-free while a location is live:
5409        // it outlives the moment and travels wherever the QR is pasted.
5410        let bundle = alice
5411            .sign_identity_bundle(Some("Alice's Phone".to_owned()), None)
5412            .await
5413            .unwrap();
5414        let record = crate::decode_node_identity(address(&alice_identity), bundle).unwrap();
5415        assert_eq!(record.signature, crate::IdentitySignatureState::Valid);
5416        assert!(
5417            record.latitude.is_none(),
5418            "a QR bundle never places its owner"
5419        );
5420
5421        // One advertisement while sharing, one after clearing.
5422        let mut received = Vec::new();
5423        for share in [true, false] {
5424            if !share {
5425                alice.set_advertised_location(None).await.unwrap();
5426            }
5427            alice.advertise_identity(None, None).await.unwrap();
5428            let deadline = Instant::now() + Duration::from_secs(10);
5429            'advert: loop {
5430                for frame in alice.poll_update().outbound_frames {
5431                    alice.complete_outbound_frame(frame.id, true).unwrap();
5432                    bob.receive(MobileMeshRxRecord {
5433                        data: frame.data,
5434                        rssi_dbm: Some(-50),
5435                        lqi: None,
5436                        snr_cb: None,
5437                    })
5438                    .unwrap();
5439                }
5440                for event in bob.poll_update().advertisement_events {
5441                    received.push(
5442                        crate::decode_node_identity(event.peer_address, event.payload).unwrap(),
5443                    );
5444                    break 'advert;
5445                }
5446                assert!(Instant::now() < deadline, "advertisement not received");
5447                std::thread::sleep(Duration::from_millis(5));
5448            }
5449        }
5450
5451        let shared = &received[0];
5452        assert_eq!(shared.location_precision, Some(5));
5453        assert!((shared.latitude.unwrap() - 37.774_929).abs() < 0.01);
5454        assert!((shared.longitude.unwrap() + 122.419_416).abs() < 0.01);
5455        // Clearing is complete: the next advertisement places nobody.
5456        assert!(received[1].latitude.is_none());
5457    }
5458
5459    /// The refusals: a coordinate that names no place, at either end of
5460    /// the record, never reaches the worker.
5461    #[test]
5462    fn a_location_that_names_no_place_is_refused() {
5463        let valid = MobileMeshSharedLocationRecord {
5464            latitude_degrees: 37.774_929,
5465            longitude_degrees: -122.419_416,
5466            precision_bytes: 5,
5467        };
5468        for broken in [
5469            // Precision zero encodes "unspecified", which the API spells
5470            // `None`; a record saying both is a confusion to reject.
5471            MobileMeshSharedLocationRecord {
5472                precision_bytes: 0,
5473                ..valid
5474            },
5475            MobileMeshSharedLocationRecord {
5476                precision_bytes: MAX_PRECISION + 1,
5477                ..valid
5478            },
5479            MobileMeshSharedLocationRecord {
5480                latitude_degrees: 90.1,
5481                ..valid
5482            },
5483            MobileMeshSharedLocationRecord {
5484                longitude_degrees: -180.1,
5485                ..valid
5486            },
5487            MobileMeshSharedLocationRecord {
5488                latitude_degrees: f64::NAN,
5489                ..valid
5490            },
5491        ] {
5492            assert!(matches!(
5493                disclosed_cell(broken),
5494                Err(MobileMeshError::InvalidLocation)
5495            ));
5496        }
5497        let cell = disclosed_cell(valid).unwrap();
5498        assert_eq!(cell.precision(), 5);
5499    }
5500
5501    /// A beacon carries no payload at all, so what reaches a listener is
5502    /// presence and a trace — never an advertisement.
5503    #[tokio::test]
5504    async fn a_beacon_reports_presence_and_carries_nothing() {
5505        let directory = tempfile::tempdir().unwrap();
5506        let alice_identity = identity(31);
5507        let bob_identity = identity(33);
5508        let alice_store =
5509            MobileCounterStore::new(directory.path().join("alice").display().to_string()).unwrap();
5510        let bob_store =
5511            MobileCounterStore::new(directory.path().join("bob").display().to_string()).unwrap();
5512        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
5513            .await
5514            .unwrap();
5515        let bob = MobileMeshSession::new(bob_identity, bob_store)
5516            .await
5517            .unwrap();
5518
5519        alice.send_beacon().await.unwrap();
5520
5521        let deadline = Instant::now() + Duration::from_secs(10);
5522        loop {
5523            for frame in alice.poll_update().outbound_frames {
5524                let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
5525                assert!(header.is_beacon(), "a beacon carries no body");
5526                let options =
5527                    umsh_core::ParsedOptions::extract(&frame.data, header.options_range.clone())
5528                        .unwrap();
5529                assert!(options.trace_route.is_some());
5530                assert!(
5531                    options.trace_signal.is_some(),
5532                    "the pair is what makes the trace worth collecting"
5533                );
5534                alice.complete_outbound_frame(frame.id, true).unwrap();
5535                bob.receive(MobileMeshRxRecord {
5536                    data: frame.data,
5537                    rssi_dbm: Some(-50),
5538                    lqi: None,
5539                    snr_cb: None,
5540                })
5541                .unwrap();
5542            }
5543            let bob_update = bob.poll_update();
5544            assert!(
5545                bob_update.advertisement_events.is_empty(),
5546                "an empty beacon identifies nobody"
5547            );
5548            if let Some(heard) = bob_update.peer_heard_events.into_iter().next() {
5549                // Hint-only, not a full key: a beacon is the cheapest
5550                // thing this phone can say, and the 29 bytes a key costs
5551                // buy nothing a listener who already knows it needs.
5552                assert_eq!(heard.peer_address, None);
5553                assert_eq!(heard.node_hint, Some(hint_of(&alice_identity)));
5554                assert!(!heard.source_authenticated);
5555                break;
5556            }
5557            assert!(Instant::now() < deadline, "beacon not received");
5558            std::thread::sleep(Duration::from_millis(5));
5559        }
5560    }
5561
5562    /// The two advertisement paths differ only in reach: a manual one
5563    /// floods and traces so a stranger can find its way back, a scheduled
5564    /// one restates to whoever can already hear this phone.
5565    #[tokio::test]
5566    async fn a_scheduled_advertisement_stays_with_the_neighbours() {
5567        let directory = tempfile::tempdir().unwrap();
5568        let local_identity = identity(41);
5569        let store =
5570            MobileCounterStore::new(directory.path().join("local").display().to_string()).unwrap();
5571        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
5572
5573        session
5574            .advertise_identity_scheduled(Some("Phone".to_owned()), None)
5575            .await
5576            .unwrap();
5577
5578        let deadline = Instant::now() + Duration::from_secs(10);
5579        loop {
5580            let frames = session.poll_update().outbound_frames;
5581            if let Some(frame) = frames.into_iter().next() {
5582                let header = umsh_core::PacketHeader::parse(&frame.data).unwrap();
5583                assert!(
5584                    header.flood_hops.is_none(),
5585                    "a scheduled advertisement is not flooded"
5586                );
5587                let options =
5588                    umsh_core::ParsedOptions::extract(&frame.data, header.options_range.clone())
5589                        .unwrap();
5590                assert!(options.trace_route.is_none());
5591                assert!(
5592                    header.fcf.full_source(),
5593                    "the detached signature is only checkable against the key"
5594                );
5595                session.complete_outbound_frame(frame.id, true).unwrap();
5596                break;
5597            }
5598            assert!(Instant::now() < deadline, "advertisement never queued");
5599            std::thread::sleep(Duration::from_millis(5));
5600        }
5601    }
5602
5603    /// The virtual-time seam: with the worker runtime's clock paused, a
5604    /// 30-second protocol timeout resolves in wall-clock milliseconds. This
5605    /// is the harness for exercising MAC ACK timeouts, repair timers, and
5606    /// retry cadences deterministically without real sleeps.
5607    #[tokio::test]
5608    async fn virtual_time_fast_forwards_protocol_timeouts() {
5609        let directory = tempfile::tempdir().unwrap();
5610        let local_identity = identity(61);
5611        let silent_peer = identity(62);
5612        let store = MobileCounterStore::new(directory.path().join("virtual").display().to_string())
5613            .unwrap();
5614        let session = MobileMeshSession::new_with_virtual_time(local_identity, store)
5615            .await
5616            .unwrap();
5617        let started = Instant::now();
5618        let operation = session.ping(address(&silent_peer), 30_000).unwrap();
5619
5620        let deadline = started + Duration::from_secs(5);
5621        loop {
5622            let update = session.poll_update();
5623            for frame in update.outbound_frames {
5624                session.complete_outbound_frame(frame.id, true).unwrap();
5625            }
5626            if let Some(event) = update.ping_events.into_iter().next() {
5627                assert_eq!(event.operation_id, operation);
5628                assert_eq!(event.outcome, MobileMeshPingOutcome::TimedOut);
5629                break;
5630            }
5631            assert!(
5632                Instant::now() < deadline,
5633                "virtual-time ping timeout never fired"
5634            );
5635            std::thread::sleep(Duration::from_millis(2));
5636        }
5637        assert!(
5638            started.elapsed() < Duration::from_secs(5),
5639            "a 30s virtual timeout must not take real-time seconds"
5640        );
5641    }
5642
5643    #[tokio::test]
5644    async fn silent_peer_completes_with_timeout_event() {
5645        let directory = tempfile::tempdir().unwrap();
5646        let local_identity = identity(11);
5647        let silent_peer = identity(13);
5648        let store =
5649            MobileCounterStore::new(directory.path().join("local").display().to_string()).unwrap();
5650        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
5651        let operation = session.ping(address(&silent_peer), 100).unwrap();
5652        let deadline = Instant::now() + Duration::from_secs(2);
5653
5654        loop {
5655            let update = session.poll_update();
5656            for frame in update.outbound_frames {
5657                session.complete_outbound_frame(frame.id, true).unwrap();
5658            }
5659            if let Some(event) = update.ping_events.into_iter().next() {
5660                assert_eq!(event.operation_id, operation);
5661                assert_eq!(event.outcome, MobileMeshPingOutcome::TimedOut);
5662                assert_eq!(event.round_trip_milliseconds, None);
5663                assert_eq!(event.hop_count, None);
5664                assert!(event.route_hints.is_empty());
5665                assert_eq!(event.rssi_dbm, None);
5666                break;
5667            }
5668            assert!(Instant::now() < deadline, "silent ping never timed out");
5669            std::thread::sleep(Duration::from_millis(10));
5670        }
5671    }
5672
5673    struct TestWakeListener {
5674        signal: std_mpsc::Sender<()>,
5675    }
5676
5677    impl MobileMeshWakeListener for TestWakeListener {
5678        fn on_update_pending(&self) {
5679            let _ = self.signal.send(());
5680        }
5681    }
5682
5683    /// The wake listener replaces platform-side polling: it must fire when
5684    /// data becomes pending without any poll_update call, coalesce while
5685    /// pending, and re-arm after each drain.
5686    #[tokio::test]
5687    async fn wake_listener_fires_on_pending_data_and_rearms_after_drain() {
5688        let directory = tempfile::tempdir().unwrap();
5689        let local_identity = identity(63);
5690        let silent_peer = identity(64);
5691        let store =
5692            MobileCounterStore::new(directory.path().join("wake").display().to_string()).unwrap();
5693        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
5694        let (signal, wakes) = std_mpsc::channel();
5695        session.set_wake_listener(Arc::new(TestWakeListener { signal }));
5696
5697        // The ping's outbound frame must announce itself with no polling.
5698        let operation = session.ping(address(&silent_peer), 100).unwrap();
5699        wakes
5700            .recv_timeout(Duration::from_secs(5))
5701            .expect("no wake for the outbound ping frame");
5702
5703        let update = session.poll_update();
5704        assert!(
5705            !update.outbound_frames.is_empty(),
5706            "wake fired but nothing was pending"
5707        );
5708        for frame in update.outbound_frames {
5709            session.complete_outbound_frame(frame.id, true).unwrap();
5710        }
5711
5712        // The drain re-armed the signal: the ping-timeout event a moment
5713        // later must produce a second wake.
5714        wakes
5715            .recv_timeout(Duration::from_secs(5))
5716            .expect("no wake for the ping timeout event");
5717        let deadline = Instant::now() + Duration::from_secs(2);
5718        loop {
5719            let update = session.poll_update();
5720            if let Some(event) = update.ping_events.into_iter().next() {
5721                assert_eq!(event.operation_id, operation);
5722                assert_eq!(event.outcome, MobileMeshPingOutcome::TimedOut);
5723                break;
5724            }
5725            assert!(Instant::now() < deadline, "timeout event never surfaced");
5726            std::thread::sleep(Duration::from_millis(5));
5727        }
5728    }
5729
5730    /// A listener registered after data is already pending is told
5731    /// immediately instead of waiting for the next protocol event.
5732    #[tokio::test]
5733    async fn wake_listener_registered_late_fires_for_already_pending_data() {
5734        let directory = tempfile::tempdir().unwrap();
5735        let local_identity = identity(65);
5736        let silent_peer = identity(66);
5737        let store =
5738            MobileCounterStore::new(directory.path().join("wake-late").display().to_string())
5739                .unwrap();
5740        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
5741
5742        session.ping(address(&silent_peer), 5_000).unwrap();
5743        // Give the worker time to enqueue the outbound frame first; even if
5744        // it loses this race, the enqueue itself fires the listener, so the
5745        // assertion below holds either way.
5746        std::thread::sleep(Duration::from_millis(200));
5747
5748        let (signal, wakes) = std_mpsc::channel();
5749        session.set_wake_listener(Arc::new(TestWakeListener { signal }));
5750        wakes
5751            .recv_timeout(Duration::from_secs(5))
5752            .expect("late-registered listener never fired");
5753        assert!(!session.poll_update().outbound_frames.is_empty());
5754    }
5755
5756    #[tokio::test]
5757    async fn chat_checkpoint_batch_gates_transmission_and_delivers_owned_mutation() {
5758        let directory = tempfile::tempdir().unwrap();
5759        let alice_identity = identity(21);
5760        let bob_identity = identity(22);
5761        let alice_root = directory.path().join("chat-alice");
5762        let alice_store = MobileCounterStore::new(alice_root.display().to_string()).unwrap();
5763        let bob_store =
5764            MobileCounterStore::new(directory.path().join("chat-bob").display().to_string())
5765                .unwrap();
5766        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
5767            .await
5768            .unwrap();
5769        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
5770            .await
5771            .unwrap();
5772        let alice_address = address(&alice_identity);
5773        alice
5774            .register_peers(vec![address(&bob_identity)])
5775            .await
5776            .unwrap();
5777        bob.register_peers(vec![alice_address.clone()])
5778            .await
5779            .unwrap();
5780
5781        let batch = alice
5782            .compose_text(address(&bob_identity), 77, "hello from Rust".to_owned())
5783            .await
5784            .unwrap();
5785        assert_eq!(
5786            batch.checkpoint.conversation_address,
5787            address(&bob_identity)
5788        );
5789        assert!(!batch.archives.is_empty());
5790        assert_eq!(batch.mutations.len(), 1);
5791        assert_eq!(batch.mutations[0].body.as_deref(), Some("hello from Rust"));
5792        assert_eq!(batch.mutations[0].fragment_count, Some(1));
5793        assert_eq!(
5794            alice
5795                .compose_text(address(&bob_identity), 78, "second".to_owned())
5796                .await,
5797            Err(MobileMeshError::OperationInProgress)
5798        );
5799        assert!(alice.poll_update().outbound_frames.is_empty());
5800        assert!(
5801            !alice_root.exists(),
5802            "compose alone must not touch counters"
5803        );
5804
5805        alice.commit_chat_batch(batch.batch_id).await.unwrap();
5806        assert!(alice_root.exists());
5807
5808        // First-contact counter synchronization plus the acknowledged fragment
5809        // pipeline can cross several scheduler ticks under loaded CI.
5810        let deadline = Instant::now() + Duration::from_secs(10);
5811        loop {
5812            let alice_update = alice.poll_update();
5813            for frame in alice_update.outbound_frames {
5814                alice.complete_outbound_frame(frame.id, true).unwrap();
5815                bob.receive(MobileMeshRxRecord {
5816                    data: frame.data,
5817                    rssi_dbm: Some(-55),
5818                    lqi: Some(200),
5819                    snr_cb: Some(70),
5820                })
5821                .unwrap();
5822            }
5823            let bob_update = bob.poll_update();
5824            for frame in bob_update.outbound_frames.iter().cloned() {
5825                bob.complete_outbound_frame(frame.id, true).unwrap();
5826                alice
5827                    .receive(MobileMeshRxRecord {
5828                        data: frame.data,
5829                        rssi_dbm: Some(-55),
5830                        lqi: Some(200),
5831                        snr_cb: Some(70),
5832                    })
5833                    .unwrap();
5834            }
5835            if let Some(mutation) = bob_update.chat_mutations.first() {
5836                assert_eq!(mutation.body.as_deref(), Some("hello from Rust"));
5837                assert_eq!(
5838                    mutation.sender_address.as_deref(),
5839                    Some(alice_address.as_str())
5840                );
5841                assert_eq!(
5842                    mutation.direction,
5843                    Some(crate::MobileChatDirection::Inbound)
5844                );
5845                let batch_id = bob_update.chat_batch_id.expect("owned chat batch");
5846                assert_eq!(
5847                    bob.poll_update().chat_batch_id,
5848                    Some(batch_id),
5849                    "unacknowledged chat effects must be replayed"
5850                );
5851                bob.acknowledge_chat_batch(batch_id).unwrap();
5852                assert!(bob.poll_update().chat_mutations.is_empty());
5853                break;
5854            }
5855            assert!(Instant::now() < deadline, "chat frame did not arrive");
5856            std::thread::sleep(Duration::from_millis(5));
5857        }
5858    }
5859
5860    #[tokio::test]
5861    async fn fragmented_chat_message_crosses_mobile_radio_bridge() {
5862        let directory = tempfile::tempdir().unwrap();
5863        let alice_identity = identity(31);
5864        let bob_identity = identity(32);
5865        let alice_store =
5866            MobileCounterStore::new(directory.path().join("long-alice").display().to_string())
5867                .unwrap();
5868        let bob_store =
5869            MobileCounterStore::new(directory.path().join("long-bob").display().to_string())
5870                .unwrap();
5871        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
5872            .await
5873            .unwrap();
5874        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
5875            .await
5876            .unwrap();
5877        let alice_address = address(&alice_identity);
5878        let bob_address = address(&bob_identity);
5879        alice
5880            .register_peers(vec![bob_address.clone()])
5881            .await
5882            .unwrap();
5883        bob.register_peers(vec![alice_address.clone()])
5884            .await
5885            .unwrap();
5886
5887        let body = "fragmented mobile message ".repeat(16);
5888        let batch = alice
5889            .compose_text(bob_address, 91, body.clone())
5890            .await
5891            .unwrap();
5892        let fragment_count = usize::from(batch.mutations[0].fragment_count.unwrap_or(1));
5893        assert!(fragment_count > 1);
5894        alice.commit_chat_batch(batch.batch_id).await.unwrap();
5895
5896        // First-contact counter synchronization plus the acknowledged fragment
5897        // pipeline can cross several scheduler ticks under loaded CI.
5898        let deadline = Instant::now() + Duration::from_secs(10);
5899        let mut outbound_lengths = Vec::new();
5900        let mut return_lengths = Vec::new();
5901        let mut receiver_complete = false;
5902        let mut sender_delivered = false;
5903        loop {
5904            let alice_update = alice.poll_update();
5905            let alice_frames = alice_update.outbound_frames;
5906            assert!(
5907                alice_frames.len() <= 1,
5908                "the mobile bridge must wait for physical TX completion"
5909            );
5910            for frame in alice_frames {
5911                outbound_lengths.push(frame.data.len());
5912                alice.complete_outbound_frame(frame.id, true).unwrap();
5913                bob.receive(MobileMeshRxRecord {
5914                    data: frame.data,
5915                    rssi_dbm: Some(-55),
5916                    lqi: Some(200),
5917                    snr_cb: Some(70),
5918                })
5919                .unwrap();
5920            }
5921            sender_delivered |= alice_update
5922                .chat_deliveries
5923                .iter()
5924                .any(|delivery| delivery.state == MobileChatDeliveryState::Acknowledged);
5925            if let Some(batch_id) = alice_update.chat_batch_id {
5926                alice.acknowledge_chat_batch(batch_id).unwrap();
5927            }
5928            let bob_update = bob.poll_update();
5929            for frame in bob_update.outbound_frames.iter().cloned() {
5930                return_lengths.push(frame.data.len());
5931                bob.complete_outbound_frame(frame.id, true).unwrap();
5932                alice
5933                    .receive(MobileMeshRxRecord {
5934                        data: frame.data,
5935                        rssi_dbm: Some(-55),
5936                        lqi: Some(200),
5937                        snr_cb: Some(70),
5938                    })
5939                    .unwrap();
5940            }
5941            if let Some(mutation) = bob_update
5942                .chat_mutations
5943                .iter()
5944                .find(|mutation| mutation.complete == Some(true))
5945            {
5946                assert_eq!(mutation.body.as_deref(), Some(body.as_str()));
5947                receiver_complete = true;
5948            }
5949            if let Some(batch_id) = bob_update.chat_batch_id {
5950                bob.acknowledge_chat_batch(batch_id).unwrap();
5951            }
5952            if receiver_complete && sender_delivered {
5953                assert!(
5954                    outbound_lengths.len() <= fragment_count * 2 + 4,
5955                    "fragment delivery was unexpectedly amplified: {outbound_lengths:?}"
5956                );
5957                break;
5958            }
5959            assert!(
5960                Instant::now() < deadline,
5961                "fragmented chat did not complete at both endpoints; receiver_complete={receiver_complete}, sender_delivered={sender_delivered}, outbound lengths: {outbound_lengths:?}; return lengths: {return_lengths:?}"
5962            );
5963            std::thread::sleep(Duration::from_millis(5));
5964        }
5965    }
5966
5967    #[tokio::test]
5968    async fn ulcp_link_failure_terminates_pending_chat_delivery() {
5969        let directory = tempfile::tempdir().unwrap();
5970        let local_identity = identity(41);
5971        let peer_identity = identity(42);
5972        let store =
5973            MobileCounterStore::new(directory.path().join("failed-send").display().to_string())
5974                .unwrap();
5975        let session = MobileMeshSession::new(local_identity, store).await.unwrap();
5976        session
5977            .register_peers(vec![address(&peer_identity)])
5978            .await
5979            .unwrap();
5980        let batch = session
5981            .compose_text(address(&peer_identity), 17, "will fail".into())
5982            .await
5983            .unwrap();
5984        session.commit_chat_batch(batch.batch_id).await.unwrap();
5985        session.fail_outbound_transmissions().unwrap();
5986
5987        let deadline = Instant::now() + Duration::from_secs(2);
5988        loop {
5989            let update = session.poll_update();
5990            if update
5991                .chat_deliveries
5992                .iter()
5993                .any(|delivery| delivery.state == MobileChatDeliveryState::Failed)
5994            {
5995                break;
5996            }
5997            if let Some(batch_id) = update.chat_batch_id {
5998                session.acknowledge_chat_batch(batch_id).unwrap();
5999            }
6000            assert!(
6001                Instant::now() < deadline,
6002                "link failure did not terminate chat delivery"
6003            );
6004            std::thread::sleep(Duration::from_millis(5));
6005        }
6006    }
6007
6008    /// A ULCP-link failure declared while one fragment awaits physical
6009    /// TX completion must also stop the fragments queued behind it in the
6010    /// MAC: without the poisoned window, the drain loop keeps handing them
6011    /// to the platform after `fail_all` bumps the generation, so a single
6012    /// BLE hiccup fans out into several wasted physical transmissions.
6013    #[tokio::test]
6014    async fn mid_batch_failure_suppresses_fragments_queued_behind_the_blocked_one() {
6015        let directory = tempfile::tempdir().unwrap();
6016        let alice_identity = identity(51);
6017        let bob_identity = identity(52);
6018        let alice_store =
6019            MobileCounterStore::new(directory.path().join("mid-alice").display().to_string())
6020                .unwrap();
6021        let bob_store =
6022            MobileCounterStore::new(directory.path().join("mid-bob").display().to_string())
6023                .unwrap();
6024        let alice = MobileMeshSession::new(alice_identity.clone(), alice_store)
6025            .await
6026            .unwrap();
6027        let bob = MobileMeshSession::new(bob_identity.clone(), bob_store)
6028            .await
6029            .unwrap();
6030        let bob_address = address(&bob_identity);
6031        alice
6032            .register_peers(vec![bob_address.clone()])
6033            .await
6034            .unwrap();
6035        bob.register_peers(vec![address(&alice_identity)])
6036            .await
6037            .unwrap();
6038
6039        // Warmup: one acknowledged message opens the fragment pipeline, so a
6040        // later multi-fragment commit enqueues every fragment into the MAC.
6041        let warmup = alice
6042            .compose_text(bob_address.clone(), 1, "warmup".to_owned())
6043            .await
6044            .unwrap();
6045        alice.commit_chat_batch(warmup.batch_id).await.unwrap();
6046        let deadline = Instant::now() + Duration::from_secs(10);
6047        loop {
6048            let alice_update = alice.poll_update();
6049            for frame in alice_update.outbound_frames {
6050                alice.complete_outbound_frame(frame.id, true).unwrap();
6051                bob.receive(MobileMeshRxRecord {
6052                    data: frame.data,
6053                    rssi_dbm: Some(-50),
6054                    lqi: None,
6055                    snr_cb: Some(80),
6056                })
6057                .unwrap();
6058            }
6059            let acked = alice_update
6060                .chat_deliveries
6061                .iter()
6062                .any(|delivery| delivery.state == MobileChatDeliveryState::Acknowledged);
6063            if let Some(batch_id) = alice_update.chat_batch_id {
6064                alice.acknowledge_chat_batch(batch_id).unwrap();
6065            }
6066            let bob_update = bob.poll_update();
6067            for frame in bob_update.outbound_frames.iter().cloned() {
6068                bob.complete_outbound_frame(frame.id, true).unwrap();
6069                alice
6070                    .receive(MobileMeshRxRecord {
6071                        data: frame.data,
6072                        rssi_dbm: Some(-50),
6073                        lqi: None,
6074                        snr_cb: Some(80),
6075                    })
6076                    .unwrap();
6077            }
6078            if let Some(batch_id) = bob_update.chat_batch_id {
6079                bob.acknowledge_chat_batch(batch_id).unwrap();
6080            }
6081            if acked {
6082                break;
6083            }
6084            assert!(Instant::now() < deadline, "warmup exchange never acked");
6085            std::thread::sleep(Duration::from_millis(5));
6086        }
6087
6088        // Fragmented message: all fragments enter the MAC queue; the drain
6089        // blocks on the first fragment's physical completion.
6090        let body = "storm test payload ".repeat(24);
6091        let batch = alice
6092            .compose_text(bob_address, 2, body.clone())
6093            .await
6094            .unwrap();
6095        assert!(batch.mutations[0].fragment_count.unwrap_or(1) > 1);
6096        alice.commit_chat_batch(batch.batch_id).await.unwrap();
6097
6098        // Wait for the first fragment to reach the platform (the worker is
6099        // now blocked awaiting its completion), then declare link failure
6100        // without completing it.
6101        let deadline = Instant::now() + Duration::from_secs(10);
6102        loop {
6103            let update = alice.poll_update();
6104            if let Some(batch_id) = update.chat_batch_id {
6105                alice.acknowledge_chat_batch(batch_id).unwrap();
6106            }
6107            if !update.outbound_frames.is_empty() {
6108                break;
6109            }
6110            assert!(
6111                Instant::now() < deadline,
6112                "first fragment never reached the platform"
6113            );
6114            std::thread::sleep(Duration::from_millis(5));
6115        }
6116        let fail_at = Instant::now();
6117        alice.fail_outbound_transmissions().unwrap();
6118
6119        // The queued fragments behind the blocked one must not surface as
6120        // new platform transmissions, and every fragment must fail promptly
6121        // (the failure report must not wait out MAC listen/ack windows).
6122        let mut saw_failed = false;
6123        let quiet_deadline = fail_at + Duration::from_millis(1_000);
6124        while Instant::now() < quiet_deadline {
6125            let update = alice.poll_update();
6126            assert!(
6127                update.outbound_frames.is_empty(),
6128                "fragments queued behind a failed batch were still dispatched"
6129            );
6130            saw_failed |= update
6131                .chat_deliveries
6132                .iter()
6133                .any(|delivery| delivery.state == MobileChatDeliveryState::Failed);
6134            if let Some(batch_id) = update.chat_batch_id {
6135                alice.acknowledge_chat_batch(batch_id).unwrap();
6136            }
6137            std::thread::sleep(Duration::from_millis(10));
6138        }
6139        assert!(saw_failed, "batch failure never reported to the transcript");
6140
6141        // Recovery: once the cancellation is processed, new sends flow again.
6142        let retry = alice
6143            .compose_text(address(&bob_identity), 3, "after failure".to_owned())
6144            .await
6145            .unwrap();
6146        alice.commit_chat_batch(retry.batch_id).await.unwrap();
6147        let deadline = Instant::now() + Duration::from_secs(10);
6148        loop {
6149            let update = alice.poll_update();
6150            if let Some(batch_id) = update.chat_batch_id {
6151                alice.acknowledge_chat_batch(batch_id).unwrap();
6152            }
6153            if !update.outbound_frames.is_empty() {
6154                break;
6155            }
6156            assert!(
6157                Instant::now() < deadline,
6158                "transmissions never resumed after failure recovery"
6159            );
6160            std::thread::sleep(Duration::from_millis(5));
6161        }
6162    }
6163
6164    /// A group message crosses two real sessions over a shared channel key.
6165    ///
6166    /// Multicast has no acknowledgement, so the sender's terminal state is
6167    /// `Sent`; the receiver attributes the message to a claimed hint and,
6168    /// because group sends carry the full source, resolves that hint to a
6169    /// real address it can name.
6170    #[tokio::test]
6171    async fn a_channel_group_message_crosses_two_sessions() {
6172        let directory = tempfile::tempdir().unwrap();
6173        let alice_identity = identity(61);
6174        let bob_identity = identity(62);
6175        let alice = MobileMeshSession::new(
6176            alice_identity.clone(),
6177            MobileCounterStore::new(directory.path().join("ch-alice").display().to_string())
6178                .unwrap(),
6179        )
6180        .await
6181        .unwrap();
6182        let bob = MobileMeshSession::new(
6183            bob_identity.clone(),
6184            MobileCounterStore::new(directory.path().join("ch-bob").display().to_string()).unwrap(),
6185        )
6186        .await
6187        .unwrap();
6188
6189        let key = vec![0x5Cu8; 32];
6190        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
6191        assert!(conversation.starts_with("ch:"));
6192        alice.register_channels(vec![key.clone()]).await.unwrap();
6193        bob.register_channels(vec![key]).await.unwrap();
6194
6195        let batch = alice
6196            .compose_text(conversation.clone(), 1, "regroup at the ridge".to_owned())
6197            .await
6198            .unwrap();
6199        assert_eq!(batch.checkpoint.conversation_address, conversation);
6200        alice.commit_chat_batch(batch.batch_id).await.unwrap();
6201
6202        let mut alice_states = Vec::new();
6203        let mut received: Option<MobileChatMutationRecord> = None;
6204        let mut resolution: Option<MobileChatSenderResolutionRecord> = None;
6205        // The sender's delivery state lands on a later protocol tick than the
6206        // receiver's transcript, so all three are waited for together.
6207        let deadline = Instant::now() + Duration::from_secs(10);
6208        while received.is_none()
6209            || resolution.is_none()
6210            || !alice_states.contains(&MobileChatDeliveryState::Sent)
6211        {
6212            let alice_update = alice.poll_update();
6213            for frame in alice_update.outbound_frames {
6214                alice.complete_outbound_frame(frame.id, true).unwrap();
6215                bob.receive(MobileMeshRxRecord {
6216                    data: frame.data,
6217                    rssi_dbm: Some(-70),
6218                    lqi: None,
6219                    snr_cb: Some(60),
6220                })
6221                .unwrap();
6222            }
6223            alice_states.extend(
6224                alice_update
6225                    .chat_deliveries
6226                    .iter()
6227                    .map(|delivery| delivery.state),
6228            );
6229            if let Some(batch_id) = alice_update.chat_batch_id {
6230                alice.acknowledge_chat_batch(batch_id).unwrap();
6231            }
6232
6233            let bob_update = bob.poll_update();
6234            for frame in bob_update.outbound_frames {
6235                bob.complete_outbound_frame(frame.id, true).unwrap();
6236            }
6237            if let Some(record) = bob_update
6238                .chat_mutations
6239                .iter()
6240                .find(|mutation| mutation.body.as_deref() == Some("regroup at the ridge"))
6241            {
6242                received = Some(record.clone());
6243            }
6244            if let Some(record) = bob_update.chat_sender_resolutions.first() {
6245                resolution = Some(record.clone());
6246            }
6247            if let Some(batch_id) = bob_update.chat_batch_id {
6248                bob.acknowledge_chat_batch(batch_id).unwrap();
6249            }
6250            assert!(
6251                Instant::now() < deadline,
6252                "group message incomplete (mutation: {}, resolution: {}, states: {alice_states:?})",
6253                received.is_some(),
6254                resolution.is_some()
6255            );
6256            std::thread::sleep(Duration::from_millis(5));
6257        }
6258
6259        let received = received.unwrap();
6260        assert_eq!(
6261            received.conversation_address.as_deref(),
6262            Some(&conversation[..])
6263        );
6264        assert_eq!(received.direction, Some(MobileChatDirection::Inbound));
6265        // The hint is what the wire carried; the address is what the full
6266        // source let the facade resolve it to.
6267        assert_eq!(
6268            received.sender_hint.as_deref(),
6269            Some(&hint_of(&alice_identity)[..])
6270        );
6271        assert_eq!(
6272            received.sender_address.as_deref(),
6273            Some(&address(&alice_identity)[..])
6274        );
6275        let rx = received
6276            .rx
6277            .expect("a received frame carries radio metadata");
6278        assert_eq!(rx.rssi_dbm, Some(-70));
6279        assert_eq!(rx.snr_centibels, Some(60));
6280        // Heard directly off the air: the link from the sender is the one
6281        // and only hop.
6282        assert_eq!(rx.hop_count, Some(1));
6283
6284        let resolution = resolution.unwrap();
6285        assert_eq!(resolution.conversation_address, conversation);
6286        assert_eq!(resolution.sender_hint, hint_of(&alice_identity));
6287        assert_eq!(resolution.sender_address, address(&alice_identity));
6288
6289        // Nothing acknowledges a multicast, so `Sent` is where it ends.
6290        assert!(alice_states.contains(&MobileChatDeliveryState::Sent));
6291        assert!(!alice_states.contains(&MobileChatDeliveryState::Acknowledged));
6292    }
6293
6294    /// `EMERGENCY` chat goes out readable, and unreadable copies are ignored.
6295    ///
6296    /// The two halves are one rule seen from both ends, so they are proven
6297    /// together: what leaves carries no encryption, and a frame that arrives
6298    /// encrypted is refused however well it authenticates. The refused frame
6299    /// here is byte-for-byte the payload the accepted one carries, sealed
6300    /// under the same channel key by the same sender — encryption is the only
6301    /// difference between the message that is shown and the message that is
6302    /// not.
6303    #[tokio::test]
6304    async fn emergency_chat_is_sent_readable_and_encrypted_copies_are_refused() {
6305        use umsh_core::{MicSize, PacketBuilder, PacketHeader};
6306        use umsh_crypto::{
6307            CryptoEngine, PairwiseKeys,
6308            software::{SoftwareAes, SoftwareSha256},
6309        };
6310
6311        let directory = tempfile::tempdir().unwrap();
6312        let alice_identity = identity(71);
6313        let alice = MobileMeshSession::new(
6314            alice_identity.clone(),
6315            MobileCounterStore::new(directory.path().join("sos-alice").display().to_string())
6316                .unwrap(),
6317        )
6318        .await
6319        .unwrap();
6320        let bob = MobileMeshSession::new(
6321            identity(72),
6322            MobileCounterStore::new(directory.path().join("sos-bob").display().to_string())
6323                .unwrap(),
6324        )
6325        .await
6326        .unwrap();
6327
6328        let key = crate::inspect_channel_name(crate::EMERGENCY_CHANNEL_NAME.to_owned())
6329            .unwrap()
6330            .key;
6331        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
6332        alice.register_channels(vec![key.clone()]).await.unwrap();
6333        bob.register_channels(vec![key.clone()]).await.unwrap();
6334
6335        let body = "tower down at mile 14";
6336        let batch = alice
6337            .compose_text(conversation.clone(), 1, body.to_owned())
6338            .await
6339            .unwrap();
6340        alice.commit_chat_batch(batch.batch_id).await.unwrap();
6341
6342        // Collect what Alice puts on the air. A message this short is one
6343        // frame; anything else the session emits is not a multicast on this
6344        // channel and is filtered out below.
6345        let engine = CryptoEngine::new(SoftwareAes, SoftwareSha256);
6346        let channel_keys =
6347            engine.derive_channel_keys(&crate::channel_key_from_bytes(&key).unwrap());
6348        let deadline = Instant::now() + Duration::from_secs(10);
6349        let mut frame = None;
6350        while frame.is_none() {
6351            for outbound in alice.poll_update().outbound_frames {
6352                alice.complete_outbound_frame(outbound.id, true).unwrap();
6353                let header = match PacketHeader::parse(&outbound.data) {
6354                    Ok(header) => header,
6355                    Err(_) => continue,
6356                };
6357                if header.channel == Some(channel_keys.channel_id)
6358                    && header.packet_type() == umsh_core::PacketType::Multicast
6359                {
6360                    frame = Some((outbound.data, header));
6361                }
6362            }
6363            assert!(Instant::now() < deadline, "no emergency frame was sent");
6364            std::thread::sleep(Duration::from_millis(5));
6365        }
6366        let (frame, header) = frame.unwrap();
6367
6368        // Half one: it left in the clear.
6369        let sec_info = header.sec_info.expect("a multicast frame carries SECINFO");
6370        assert!(
6371            !sec_info.scf.encrypted(),
6372            "emergency chat must be readable by any node in range"
6373        );
6374        assert!(
6375            matches!(header.source, umsh_core::SourceAddrRef::FullKeyAt { .. }),
6376            "emergency chat must name its sender outright"
6377        );
6378
6379        // Half two: the same payload, from the same sender, under the same
6380        // channel key — encrypted. It authenticates perfectly and must still
6381        // be refused. An earlier frame counter keeps it ahead of the real
6382        // frame in the channel's replay window, so the genuine copy that
6383        // follows is judged on its own merits.
6384        let payload = {
6385            let mut opened = frame.clone();
6386            let range = engine
6387                .open_packet(
6388                    &mut opened,
6389                    &header,
6390                    &PairwiseKeys {
6391                        k_enc: channel_keys.k_enc,
6392                        k_mic: channel_keys.k_mic,
6393                    },
6394                )
6395                .unwrap();
6396            opened[range].to_vec()
6397        };
6398        assert!(
6399            sec_info.frame_counter > 0,
6400            "the forged copy needs a lower counter than the genuine one"
6401        );
6402        let alice_key = decode_peer(&address(&alice_identity)).unwrap();
6403        let mut buf = [0u8; 256];
6404        let mut forged = PacketBuilder::new(&mut buf)
6405            .multicast(channel_keys.channel_id)
6406            .source_full(&alice_key)
6407            .frame_counter(sec_info.frame_counter - 1)
6408            .encrypted()
6409            .mic_size(MicSize::Mic16)
6410            .payload(&payload)
6411            .build()
6412            .unwrap();
6413        engine
6414            .seal_packet(
6415                &mut forged,
6416                &PairwiseKeys {
6417                    k_enc: channel_keys.k_enc,
6418                    k_mic: channel_keys.k_mic,
6419                },
6420            )
6421            .unwrap();
6422        bob.receive(MobileMeshRxRecord {
6423            data: forged.as_bytes().to_vec(),
6424            rssi_dbm: Some(-70),
6425            lqi: None,
6426            snr_cb: Some(60),
6427        })
6428        .unwrap();
6429
6430        let mut refusal = None;
6431        let deadline = Instant::now() + Duration::from_secs(10);
6432        while refusal.is_none() {
6433            let update = bob.poll_update();
6434            assert!(
6435                !update
6436                    .chat_mutations
6437                    .iter()
6438                    .any(|mutation| mutation.body.as_deref() == Some(body)),
6439                "an encrypted emergency frame reached the transcript"
6440            );
6441            refusal = update
6442                .chat_diagnostics
6443                .iter()
6444                .find(|line| line.contains("emergency-channel"))
6445                .cloned();
6446            if let Some(batch_id) = update.chat_batch_id {
6447                bob.acknowledge_chat_batch(batch_id).unwrap();
6448            }
6449            assert!(
6450                Instant::now() < deadline,
6451                "the encrypted copy was not refused"
6452            );
6453            std::thread::sleep(Duration::from_millis(5));
6454        }
6455        assert!(refusal.unwrap().contains("encrypted"));
6456
6457        // And the genuine one, differing only in that it is readable, lands.
6458        bob.receive(MobileMeshRxRecord {
6459            data: frame,
6460            rssi_dbm: Some(-70),
6461            lqi: None,
6462            snr_cb: Some(60),
6463        })
6464        .unwrap();
6465        let deadline = Instant::now() + Duration::from_secs(10);
6466        let mut received = false;
6467        while !received {
6468            let update = bob.poll_update();
6469            received = update
6470                .chat_mutations
6471                .iter()
6472                .any(|mutation| mutation.body.as_deref() == Some(body));
6473            if let Some(batch_id) = update.chat_batch_id {
6474                bob.acknowledge_chat_batch(batch_id).unwrap();
6475            }
6476            assert!(Instant::now() < deadline, "the readable copy never arrived");
6477            std::thread::sleep(Duration::from_millis(5));
6478        }
6479    }
6480
6481    /// Repair still works once emergency traffic stops being encrypted.
6482    ///
6483    /// A resend request goes out channel-addressed and, on `EMERGENCY`, in
6484    /// the clear, so it is a frame the receiving gate now judges: were the
6485    /// two halves of the rule out of step, a dropped fragment there could
6486    /// never be recovered and a long emergency message would never assemble.
6487    ///
6488    /// It also covers group repair as such, which nothing else does: it is
6489    /// the only test where a member has to ask for a fragment and get it.
6490    /// Two separate faults used to stop that dead — the requester could not
6491    /// address a channel member it had never registered as a peer, and the
6492    /// sender refused to serve any frame still sitting in `in_flight`, which
6493    /// a multicast never left. Either one alone leaves this failing.
6494    ///
6495    /// Runs on the real clock, and takes the repair grace period in real
6496    /// seconds because of it. Virtual time is faster but not usable here:
6497    /// the runtime leaps to the next deadline whenever it is idle, and a
6498    /// test that drives it from outside idles constantly, so under load the
6499    /// reassembly can age out its whole 90-second lifetime between two
6500    /// polls.
6501    #[tokio::test]
6502    async fn a_dropped_emergency_fragment_is_repaired() {
6503        let directory = tempfile::tempdir().unwrap();
6504        let alice = MobileMeshSession::new(
6505            identity(73),
6506            MobileCounterStore::new(directory.path().join("sos-frag-a").display().to_string())
6507                .unwrap(),
6508        )
6509        .await
6510        .unwrap();
6511        let bob = MobileMeshSession::new(
6512            identity(74),
6513            MobileCounterStore::new(directory.path().join("sos-frag-b").display().to_string())
6514                .unwrap(),
6515        )
6516        .await
6517        .unwrap();
6518
6519        let key = crate::inspect_channel_name(crate::EMERGENCY_CHANNEL_NAME.to_owned())
6520            .unwrap()
6521            .key;
6522        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
6523        alice.register_channels(vec![key.clone()]).await.unwrap();
6524        bob.register_channels(vec![key]).await.unwrap();
6525
6526        let body: String = (0..600)
6527            .map(|index| char::from(b'a' + (index % 26) as u8))
6528            .collect();
6529        let batch = alice
6530            .compose_text(conversation.clone(), 1, body.clone())
6531            .await
6532            .unwrap();
6533        assert!(batch.mutations[0].fragment_count.unwrap() > 1);
6534        // Alice's own archive, as the platform would keep it: the only thing
6535        // she can answer a resend request out of.
6536        let archives: std::collections::HashMap<(u8, Option<u8>), Vec<u8>> = batch
6537            .archives
6538            .iter()
6539            .map(|archive| {
6540                (
6541                    (archive.message_id, archive.fragment_index),
6542                    archive.payload.clone(),
6543                )
6544            })
6545            .collect();
6546        alice.commit_chat_batch(batch.batch_id).await.unwrap();
6547
6548        let mut sent = 0;
6549        let mut repairs = 0;
6550        let mut assembled: Option<String> = None;
6551        let deadline = Instant::now() + Duration::from_secs(40);
6552        while assembled.as_deref() != Some(body.as_str()) {
6553            let alice_update = alice.poll_update();
6554            for frame in alice_update.outbound_frames {
6555                alice.complete_outbound_frame(frame.id, true).unwrap();
6556                sent += 1;
6557                // The radio eats the second fragment. Everything after it
6558                // gets through, so only a repair can complete the message.
6559                if sent == 2 {
6560                    continue;
6561                }
6562                bob.receive(MobileMeshRxRecord {
6563                    data: frame.data,
6564                    rssi_dbm: Some(-70),
6565                    lqi: None,
6566                    snr_cb: Some(60),
6567                })
6568                .unwrap();
6569            }
6570            for lookup in &alice_update.chat_archive_lookups {
6571                match archives.get(&(lookup.message_id, lookup.fragment_index)) {
6572                    Some(payload) => alice
6573                        .apply_chat_archive_result(
6574                            lookup.request_id,
6575                            MobileChatArchiveResultKind::Found,
6576                            payload.clone(),
6577                        )
6578                        .unwrap(),
6579                    None => alice
6580                        .apply_chat_archive_result(
6581                            lookup.request_id,
6582                            MobileChatArchiveResultKind::Unknown,
6583                            Vec::new(),
6584                        )
6585                        .unwrap(),
6586                }
6587            }
6588            if let Some(batch_id) = alice_update.chat_batch_id {
6589                alice.acknowledge_chat_batch(batch_id).unwrap();
6590            }
6591
6592            let bob_update = bob.poll_update();
6593            for frame in bob_update.outbound_frames {
6594                bob.complete_outbound_frame(frame.id, true).unwrap();
6595                repairs += 1;
6596                alice
6597                    .receive(MobileMeshRxRecord {
6598                        data: frame.data,
6599                        rssi_dbm: Some(-70),
6600                        lqi: None,
6601                        snr_cb: Some(60),
6602                    })
6603                    .unwrap();
6604            }
6605            for mutation in &bob_update.chat_mutations {
6606                if let Some(text) = mutation.body.as_deref() {
6607                    assembled = Some(text.to_owned());
6608                }
6609            }
6610            if let Some(batch_id) = bob_update.chat_batch_id {
6611                bob.acknowledge_chat_batch(batch_id).unwrap();
6612            }
6613            assert!(
6614                Instant::now() < deadline,
6615                "a dropped emergency fragment was never repaired \
6616                 ({repairs} repair frame(s), assembled {:?})",
6617                assembled.as_ref().map(|text| text.len())
6618            );
6619            std::thread::sleep(Duration::from_millis(5));
6620        }
6621        assert!(repairs > 0, "the message assembled without any repair");
6622    }
6623
6624    /// A repeater's copy of our own group message is not a second message.
6625    ///
6626    /// Every multicast send carries our full source address so strangers can
6627    /// address repairs to us, which means a relayed copy comes back naming us
6628    /// as the sender. Feeding that to the transcript would show the user
6629    /// their own message twice — once as sent, once as received from
6630    /// themselves.
6631    #[tokio::test]
6632    async fn a_relayed_copy_of_our_own_group_message_is_not_transcribed() {
6633        let directory = tempfile::tempdir().unwrap();
6634        let identity = identity(67);
6635        let session = MobileMeshSession::new(
6636            identity.clone(),
6637            MobileCounterStore::new(directory.path().join("echo").display().to_string()).unwrap(),
6638        )
6639        .await
6640        .unwrap();
6641
6642        let key = vec![0x3Eu8; 32];
6643        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
6644        session.register_channels(vec![key]).await.unwrap();
6645
6646        let batch = session
6647            .compose_text(conversation.clone(), 1, "anyone out there".to_owned())
6648            .await
6649            .unwrap();
6650        session.commit_chat_batch(batch.batch_id).await.unwrap();
6651
6652        // Feed every frame the session emits straight back into it, which is
6653        // exactly what a repeater in range does.
6654        let deadline = Instant::now() + Duration::from_secs(10);
6655        let mut echoed = 0;
6656        let mut inbound = Vec::new();
6657        while echoed == 0
6658            || Instant::now() < deadline.min(Instant::now() + Duration::from_millis(1))
6659        {
6660            let update = session.poll_update();
6661            for frame in update.outbound_frames {
6662                session.complete_outbound_frame(frame.id, true).unwrap();
6663                session
6664                    .receive(MobileMeshRxRecord {
6665                        data: frame.data,
6666                        rssi_dbm: Some(-60),
6667                        lqi: None,
6668                        snr_cb: Some(70),
6669                    })
6670                    .unwrap();
6671                echoed += 1;
6672            }
6673            inbound.extend(
6674                update
6675                    .chat_mutations
6676                    .iter()
6677                    .filter(|mutation| mutation.direction == Some(MobileChatDirection::Inbound))
6678                    .cloned(),
6679            );
6680            if let Some(batch_id) = update.chat_batch_id {
6681                session.acknowledge_chat_batch(batch_id).unwrap();
6682            }
6683            if echoed > 0 && Instant::now() > deadline {
6684                break;
6685            }
6686            std::thread::sleep(Duration::from_millis(5));
6687            if echoed > 0 {
6688                // Give the echo every chance to be (wrongly) transcribed.
6689                for _ in 0..20 {
6690                    let update = session.poll_update();
6691                    inbound.extend(
6692                        update
6693                            .chat_mutations
6694                            .iter()
6695                            .filter(|mutation| {
6696                                mutation.direction == Some(MobileChatDirection::Inbound)
6697                            })
6698                            .cloned(),
6699                    );
6700                    if let Some(batch_id) = update.chat_batch_id {
6701                        session.acknowledge_chat_batch(batch_id).unwrap();
6702                    }
6703                    std::thread::sleep(Duration::from_millis(5));
6704                }
6705                break;
6706            }
6707        }
6708
6709        assert!(echoed > 0, "the session never transmitted the message");
6710        assert!(
6711            inbound.is_empty(),
6712            "our own relayed message was transcribed as inbound: {inbound:?}"
6713        );
6714    }
6715
6716    /// A message addressed to our own key is a message, not an echo.
6717    ///
6718    /// The unicast goes out naming us as both sender and destination, so it
6719    /// arrives looking exactly like the relayed group copy above; only the
6720    /// packet family tells them apart. Nothing can carry it without a
6721    /// neighbor willing to repeat it, which is what the echo below stands in
6722    /// for.
6723    #[tokio::test]
6724    async fn a_message_addressed_to_ourselves_is_transcribed() {
6725        let directory = tempfile::tempdir().unwrap();
6726        let identity = identity(71);
6727        let session = MobileMeshSession::new(
6728            identity.clone(),
6729            MobileCounterStore::new(directory.path().join("self").display().to_string()).unwrap(),
6730        )
6731        .await
6732        .unwrap();
6733
6734        let own_address = address(&identity);
6735        session
6736            .register_peers(vec![own_address.clone()])
6737            .await
6738            .unwrap();
6739
6740        let body = "note to self".to_owned();
6741        let batch = session
6742            .compose_text(own_address, 7, body.clone())
6743            .await
6744            .unwrap();
6745        session.commit_chat_batch(batch.batch_id).await.unwrap();
6746
6747        // Stand in for the repeater: every frame the session emits goes back
6748        // in, including the acknowledgment it composes for its own message.
6749        let deadline = Instant::now() + Duration::from_secs(10);
6750        let mut inbound = Vec::new();
6751        while Instant::now() < deadline {
6752            let update = session.poll_update();
6753            for frame in update.outbound_frames {
6754                session.complete_outbound_frame(frame.id, true).unwrap();
6755                session
6756                    .receive(MobileMeshRxRecord {
6757                        data: frame.data,
6758                        rssi_dbm: Some(-60),
6759                        lqi: None,
6760                        snr_cb: Some(70),
6761                    })
6762                    .unwrap();
6763            }
6764            inbound.extend(
6765                update
6766                    .chat_mutations
6767                    .iter()
6768                    .filter(|mutation| mutation.direction == Some(MobileChatDirection::Inbound))
6769                    .cloned(),
6770            );
6771            if let Some(batch_id) = update.chat_batch_id {
6772                session.acknowledge_chat_batch(batch_id).unwrap();
6773            }
6774            if !inbound.is_empty() {
6775                break;
6776            }
6777            std::thread::sleep(Duration::from_millis(5));
6778        }
6779
6780        assert!(
6781            inbound
6782                .iter()
6783                .any(|mutation| mutation.body.as_deref() == Some(body.as_str())),
6784            "the message we sent ourselves never arrived: {inbound:?}"
6785        );
6786    }
6787
6788    /// Composing needs a channel this session actually holds: an address for
6789    /// an unregistered key, and an address for a channel that was left, are
6790    /// both refused rather than silently sent nowhere.
6791    #[tokio::test]
6792    async fn composing_to_an_unheld_channel_is_refused() {
6793        let directory = tempfile::tempdir().unwrap();
6794        let session = MobileMeshSession::new(
6795            identity(63),
6796            MobileCounterStore::new(directory.path().join("unheld").display().to_string()).unwrap(),
6797        )
6798        .await
6799        .unwrap();
6800
6801        let key = vec![0x77u8; 32];
6802        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
6803        assert_eq!(
6804            session
6805                .compose_text(conversation.clone(), 1, "hello".to_owned())
6806                .await,
6807            Err(MobileMeshError::UnknownConversation)
6808        );
6809
6810        session.register_channels(vec![key.clone()]).await.unwrap();
6811        let batch = session
6812            .compose_text(conversation.clone(), 2, "hello".to_owned())
6813            .await
6814            .expect("a joined channel composes");
6815        // Rejected rather than committed: this test is about which addresses
6816        // resolve, and an uncommitted batch would block the next compose.
6817        session
6818            .reject_chat_batch(batch.batch_id, Vec::new())
6819            .await
6820            .unwrap();
6821
6822        session.remove_channels(vec![key]).await.unwrap();
6823        assert_eq!(
6824            session
6825                .compose_text(conversation, 3, "hello".to_owned())
6826                .await,
6827            Err(MobileMeshError::UnknownConversation)
6828        );
6829    }
6830
6831    /// A malformed conversation address is rejected the same way, rather than
6832    /// being taken for a peer address and failing somewhere less obvious.
6833    #[tokio::test]
6834    async fn a_malformed_conversation_address_is_refused() {
6835        let directory = tempfile::tempdir().unwrap();
6836        let session = MobileMeshSession::new(
6837            identity(64),
6838            MobileCounterStore::new(directory.path().join("malformed").display().to_string())
6839                .unwrap(),
6840        )
6841        .await
6842        .unwrap();
6843        for address in ["ch:not-hex", "ch:0011", "definitely not base58 !!"] {
6844            assert_eq!(
6845                session
6846                    .compose_text(address.to_owned(), 1, "hello".to_owned())
6847                    .await,
6848                Err(MobileMeshError::UnknownConversation),
6849                "{address} should not resolve to a conversation"
6850            );
6851        }
6852    }
6853
6854    /// Direct chat keeps working, and now reports the radio metadata of the
6855    /// frame each inbound message arrived on.
6856    #[tokio::test]
6857    async fn direct_chat_still_delivers_and_now_carries_radio_metadata() {
6858        let directory = tempfile::tempdir().unwrap();
6859        let alice_identity = identity(65);
6860        let bob_identity = identity(66);
6861        let alice = MobileMeshSession::new(
6862            alice_identity.clone(),
6863            MobileCounterStore::new(directory.path().join("dm-alice").display().to_string())
6864                .unwrap(),
6865        )
6866        .await
6867        .unwrap();
6868        let bob = MobileMeshSession::new(
6869            bob_identity.clone(),
6870            MobileCounterStore::new(directory.path().join("dm-bob").display().to_string()).unwrap(),
6871        )
6872        .await
6873        .unwrap();
6874        let bob_address = address(&bob_identity);
6875        alice
6876            .register_peers(vec![bob_address.clone()])
6877            .await
6878            .unwrap();
6879        bob.register_peers(vec![address(&alice_identity)])
6880            .await
6881            .unwrap();
6882
6883        let batch = alice
6884            .compose_text(bob_address.clone(), 1, "still here".to_owned())
6885            .await
6886            .unwrap();
6887        assert_eq!(batch.checkpoint.conversation_address, bob_address);
6888        alice.commit_chat_batch(batch.batch_id).await.unwrap();
6889
6890        let deadline = Instant::now() + Duration::from_secs(10);
6891        let received = loop {
6892            let alice_update = alice.poll_update();
6893            for frame in alice_update.outbound_frames {
6894                alice.complete_outbound_frame(frame.id, true).unwrap();
6895                bob.receive(MobileMeshRxRecord {
6896                    data: frame.data,
6897                    rssi_dbm: Some(-55),
6898                    lqi: None,
6899                    snr_cb: Some(75),
6900                })
6901                .unwrap();
6902            }
6903            if let Some(batch_id) = alice_update.chat_batch_id {
6904                alice.acknowledge_chat_batch(batch_id).unwrap();
6905            }
6906            let bob_update = bob.poll_update();
6907            for frame in bob_update.outbound_frames {
6908                bob.complete_outbound_frame(frame.id, true).unwrap();
6909                alice
6910                    .receive(MobileMeshRxRecord {
6911                        data: frame.data,
6912                        rssi_dbm: Some(-55),
6913                        lqi: None,
6914                        snr_cb: Some(75),
6915                    })
6916                    .unwrap();
6917            }
6918            let found = bob_update
6919                .chat_mutations
6920                .iter()
6921                .find(|mutation| mutation.body.as_deref() == Some("still here"))
6922                .cloned();
6923            if let Some(batch_id) = bob_update.chat_batch_id {
6924                bob.acknowledge_chat_batch(batch_id).unwrap();
6925            }
6926            if let Some(found) = found {
6927                break found;
6928            }
6929            assert!(
6930                Instant::now() < deadline,
6931                "the direct message never arrived"
6932            );
6933            std::thread::sleep(Duration::from_millis(5));
6934        };
6935
6936        assert_eq!(
6937            received.conversation_address.as_deref(),
6938            Some(&address(&alice_identity)[..])
6939        );
6940        // A direct sender is individually authenticated, so there is no hint
6941        // standing in for an identity.
6942        assert_eq!(received.sender_hint, None);
6943        assert_eq!(
6944            received.sender_address.as_deref(),
6945            Some(&address(&alice_identity)[..])
6946        );
6947        let rx = received
6948            .rx
6949            .expect("a received frame carries radio metadata");
6950        assert_eq!(rx.rssi_dbm, Some(-55));
6951        assert_eq!(rx.snr_centibels, Some(75));
6952        assert!(rx.source_authenticated);
6953    }
6954
6955    /// A batch id is issued exactly when the batch has events in it, and never
6956    /// otherwise. The platform reads the id as its whole signal to apply and
6957    /// acknowledge, so a batch made of only one kind of event — a lone sender
6958    /// resolution, say — must still be announced. One batch left
6959    /// unacknowledged holds the slot for the rest of the session, and every
6960    /// delivery receipt behind it never arrives: messages transmit fine and
6961    /// stay on "Sending" forever, in every conversation at once.
6962    fn assert_batch_id_matches_events(update: &MobileMeshSessionUpdateRecord) {
6963        let has_events = !update.chat_mutations.is_empty()
6964            || !update.chat_deliveries.is_empty()
6965            || !update.chat_archive_lookups.is_empty()
6966            || !update.chat_sender_resolutions.is_empty()
6967            || !update.chat_diagnostics.is_empty();
6968        assert_eq!(
6969            update.chat_batch_id.is_some(),
6970            has_events,
6971            "batch id {:?} disagrees with the batch's contents",
6972            update.chat_batch_id
6973        );
6974    }
6975
6976    /// A fragmented group message must arrive whole.
6977    ///
6978    /// Multicast is never acknowledged, so nothing downstream may treat an ack
6979    /// as the signal to release the next fragment: every fragment has to reach
6980    /// the air on transmission alone.
6981    #[tokio::test]
6982    async fn a_fragmented_channel_group_message_arrives_whole() {
6983        let directory = tempfile::tempdir().unwrap();
6984        let alice_identity = identity(71);
6985        let bob_identity = identity(72);
6986        let alice = MobileMeshSession::new(
6987            alice_identity.clone(),
6988            MobileCounterStore::new(directory.path().join("frag-alice").display().to_string())
6989                .unwrap(),
6990        )
6991        .await
6992        .unwrap();
6993        let bob = MobileMeshSession::new(
6994            bob_identity.clone(),
6995            MobileCounterStore::new(directory.path().join("frag-bob").display().to_string())
6996                .unwrap(),
6997        )
6998        .await
6999        .unwrap();
7000
7001        let key = vec![0x9Au8; 32];
7002        let conversation = crate::channel_conversation_address(key.clone()).unwrap();
7003        alice.register_channels(vec![key.clone()]).await.unwrap();
7004        bob.register_channels(vec![key]).await.unwrap();
7005
7006        // Comfortably past a single frame, so the engine must fragment.
7007        let body: String = (0..600)
7008            .map(|index| char::from(b'a' + (index % 26) as u8))
7009            .collect();
7010        let batch = alice
7011            .compose_text(conversation.clone(), 1, body.clone())
7012            .await
7013            .unwrap();
7014        let fragments = batch.mutations[0].fragment_count.unwrap();
7015        assert!(
7016            fragments > 1,
7017            "the test body must fragment, got {fragments}"
7018        );
7019        alice.commit_chat_batch(batch.batch_id).await.unwrap();
7020
7021        let mut transmitted = 0;
7022        let mut repairs = 0;
7023        let mut assembled: Option<String> = None;
7024        let deadline = Instant::now() + Duration::from_secs(15);
7025        while assembled.as_deref() != Some(body.as_str()) {
7026            let alice_update = alice.poll_update();
7027            assert_batch_id_matches_events(&alice_update);
7028            for frame in alice_update.outbound_frames {
7029                alice.complete_outbound_frame(frame.id, true).unwrap();
7030                transmitted += 1;
7031                bob.receive(MobileMeshRxRecord {
7032                    data: frame.data,
7033                    rssi_dbm: Some(-70),
7034                    lqi: None,
7035                    snr_cb: Some(60),
7036                })
7037                .unwrap();
7038            }
7039            if let Some(batch_id) = alice_update.chat_batch_id {
7040                alice.acknowledge_chat_batch(batch_id).unwrap();
7041            }
7042
7043            let bob_update = bob.poll_update();
7044            assert_batch_id_matches_events(&bob_update);
7045            for frame in bob_update.outbound_frames {
7046                bob.complete_outbound_frame(frame.id, true).unwrap();
7047                // Bob has nothing to say on his own account: anything he
7048                // transmits is a request to have a fragment resent.
7049                repairs += 1;
7050                alice
7051                    .receive(MobileMeshRxRecord {
7052                        data: frame.data,
7053                        rssi_dbm: Some(-70),
7054                        lqi: None,
7055                        snr_cb: Some(60),
7056                    })
7057                    .unwrap();
7058            }
7059            for mutation in &bob_update.chat_mutations {
7060                if let Some(text) = mutation.body.as_deref() {
7061                    assembled = Some(text.to_owned());
7062                }
7063            }
7064            if let Some(batch_id) = bob_update.chat_batch_id {
7065                bob.acknowledge_chat_batch(batch_id).unwrap();
7066            }
7067            assert!(
7068                Instant::now() < deadline,
7069                "fragmented group message never completed \
7070                 ({transmitted} frame(s) transmitted of {fragments}, \
7071                 assembled {:?})",
7072                assembled.as_ref().map(|text| text.len())
7073            );
7074            std::thread::sleep(Duration::from_millis(5));
7075        }
7076
7077        // Every fragment reached the air off the original send. Had the
7078        // sender stalled waiting for an acknowledgement that a multicast
7079        // never produces, the message could only have completed through
7080        // Bob asking for the rest — so a repair here would mean the
7081        // transmit path is ack-gated even though the transcript recovered.
7082        assert!(
7083            transmitted >= usize::from(fragments),
7084            "only {transmitted} of {fragments} fragment(s) were transmitted"
7085        );
7086        assert_eq!(repairs, 0, "the message needed {repairs} repair request(s)");
7087    }
7088}