Skip to main content

offline_protocol_transport/
ble.rs

1//! Bluetooth Low Energy (BLE) transport queue engine.
2//!
3//! No BLE radio is touched here. Advertising, scanning, GATT operations,
4//! and characteristic writes are all owned by the platform side
5//! (CoreBluetooth on iOS, `android.bluetooth` on Android). The Rust side
6//! handles:
7//! - Send/receive queues, metrics, and the peer registry
8//! - Message fragmentation for MTU-limited links, and reassembly of
9//!   incoming fragments with capacity-based eviction
10//! - Per-peer MTU accounting ([`BleTransport::set_peer_mtu`])
11//!
12//! The bridge contract: the platform reports adapter state via
13//! [`BleTransport::on_status_changed`] and peers via
14//! [`BleTransport::on_peer_discovered`] / [`BleTransport::on_peer_lost`],
15//! drains outbound fragments with [`BleTransport::get_next_fragment`]
16//! (woken by the [`BleTransport::set_on_fragments_available`] callback) and
17//! writes them to GATT characteristics, and injects received bytes via
18//! [`BleTransport::on_fragment_received`].
19//!
20//! Unlike the other transports, BLE's `start()` optimistically sets the
21//! status to `Available`; the platform overrides it via
22//! `on_status_changed()` if BLE is actually unavailable.
23
24use crate::constants::{
25    BLE_FRAGMENT_TIMEOUT_SECS, BLE_MAX_FRAGMENT_ASSEMBLIES, BLE_MAX_FRAGMENT_COUNT,
26    BLE_MAX_FRAGMENT_SIZE, DEFAULT_MAX_MESSAGE_SIZE, FRAGMENT_HEADER_FIXED, FRAGMENT_MAGIC,
27    FRAGMENT_VERSION, MAX_REASONABLE_BLE_PAYLOAD,
28};
29use crate::{Result, SharedCallback, Transport, TransportMetrics, TransportStatus, TransportType};
30use offline_protocol_core::{Message, MutexExt};
31use offline_protocol_sealed::ACK_FOR_KEY;
32use std::collections::{HashMap, VecDeque};
33use std::convert::TryInto;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::sync::{Arc, Mutex};
36use std::time::{Duration as StdDuration, SystemTime};
37
38/// Information about a fragment assembly that was evicted due to capacity pressure.
39#[derive(Debug, Clone)]
40#[non_exhaustive]
41pub struct FragmentEvictionInfo {
42    /// ID of the message whose fragment assembly was evicted.
43    pub message_id: String,
44    /// Completion percentage (0–100) at the time of eviction.
45    pub completion_percent: u8,
46}
47
48/// Callback invoked when a fragment assembly is evicted from the reassembly cache.
49pub type FragmentEvictionCallback = Arc<dyn Fn(FragmentEvictionInfo) + Send + Sync>;
50
51/// Peer device information
52#[derive(Debug, Clone)]
53pub struct PeerDevice {
54    /// Device ID (user_id)
55    pub device_id: String,
56    /// BLE address (platform-specific)
57    pub address: String,
58    /// Signal strength in dBm
59    pub rssi: i16,
60    /// Last seen timestamp
61    pub last_seen: std::time::SystemTime,
62    /// Connection status
63    pub connected: bool,
64}
65
66#[derive(Debug, Clone)]
67struct DecodedFragment {
68    message_id: String,
69    fragment_index: u16,
70    total_fragments: u16,
71    data: Vec<u8>,
72}
73
74/// Reassembly buffer for incoming fragments
75#[derive(Debug)]
76struct FragmentAssembly {
77    /// Total expected fragments
78    total_fragments: u16,
79    /// Received fragments (index -> data)
80    fragments: HashMap<u16, Vec<u8>>,
81    /// First fragment received time. Anchors the assembly-latency metric and
82    /// the eviction-priority age penalty — NOT the idle timeout.
83    started_at: SystemTime,
84    /// Time the most recent fragment was inserted. The idle timeout in
85    /// [`Self::cleanup_fragment_buffers`] is measured from this, not
86    /// [`started_at`], so a large/slow multi-fragment message that is still
87    /// arriving is not evicted mid-flight just because its first fragment is
88    /// old (e.g. a backgrounded receiver, a 100-200ms connection interval, or
89    /// an iOS->Android sender with no per-write pacing).
90    last_seen: SystemTime,
91    /// Running sum of the payload lengths currently stored in `fragments`.
92    /// Maintained incrementally so the [`DEFAULT_MAX_MESSAGE_SIZE`] bound can
93    /// be enforced as fragments arrive, rather than only after the assembly
94    /// completes — otherwise a peer could park unbounded memory in a partial
95    /// assembly that never reaches its declared `total_fragments`.
96    buffered_bytes: usize,
97}
98
99impl FragmentAssembly {
100    /// Returns the completion ratio (0.0 to 1.0) of this assembly.
101    fn completion_ratio(&self) -> f32 {
102        if self.total_fragments == 0 {
103            return 0.0;
104        }
105        self.fragments.len() as f32 / self.total_fragments as f32
106    }
107
108    /// Returns a priority score for eviction (lower = more likely to be evicted).
109    /// Prioritizes keeping near-complete assemblies.
110    fn eviction_priority(&self, now: SystemTime) -> f32 {
111        let completion = self.completion_ratio();
112
113        // Age factor: older assemblies are slightly less valuable
114        let age_secs = now
115            .duration_since(self.started_at)
116            .unwrap_or(StdDuration::from_secs(0))
117            .as_secs_f32();
118        let age_penalty = (age_secs / 60.0).min(1.0) * 0.2; // Max 20% penalty for age
119
120        // Priority = completion ratio (0-1) minus age penalty
121        // Higher value = more valuable = less likely to evict
122        completion - age_penalty
123    }
124}
125
126/// BLE transport implementation.
127///
128/// This is a platform-agnostic abstraction. The actual BLE operations
129/// are delegated to platform-specific implementations via callbacks.
130pub struct BleTransport {
131    /// Local device ID
132    device_id: String,
133    /// Transport status
134    status: Arc<Mutex<TransportStatus>>,
135    /// Discovered peers
136    peers: Arc<Mutex<HashMap<String, PeerDevice>>>,
137    /// Received message queue
138    receive_queue: Arc<Mutex<VecDeque<Message>>>,
139    /// Send queue
140    send_queue: Arc<Mutex<VecDeque<(String, Message)>>>,
141    /// Pending serialized fragments waiting to be delivered
142    #[allow(clippy::type_complexity)]
143    pending_fragments: Arc<Mutex<VecDeque<(String, Vec<u8>)>>>,
144    /// Transport metrics.
145    ///
146    /// BLE never writes its own `success_count` / `failure_count`: it has no
147    /// send confirmation loop, so the transport manager fills delivery
148    /// evidence from the engine's acknowledgements instead. Counting a
149    /// receive-side reassembly drop here, with no success ever recorded to
150    /// balance it, reported a permanent 0% delivery ratio after one lost
151    /// fragment and drove DORS into its emergency switch (#557).
152    metrics: Arc<Mutex<TransportMetrics>>,
153    /// Platform-specific handle (opaque pointer)
154    platform_handle: Arc<Mutex<Option<usize>>>,
155    /// Fragment reassembly buffers
156    fragment_buffers: Arc<Mutex<HashMap<String, FragmentAssembly>>>,
157    /// Per-peer maximum fragment payload in bytes, keyed by peer device id.
158    ///
159    /// Populated by the platform layer after BLE MTU negotiation completes
160    /// for a given link. Stored values are already header-adjusted (i.e.
161    /// "max usable payload", not the raw ATT MTU): iOS's
162    /// `maximumWriteValueLength(for:)` returns this directly, and the Android
163    /// facade subtracts the 3-byte ATT header before calling in. Peers with
164    /// no entry fall back to [`BLE_MAX_FRAGMENT_SIZE`].
165    ///
166    /// **Keying contract:** entries MUST be keyed by the same string the
167    /// platform layer passes to [`Self::on_peer_discovered`] as
168    /// `PeerDevice::device_id`, which is also the string that lands in
169    /// [`Message::recipient`] for outbound sends. Today the codebase treats
170    /// the BLE device id and the peer's `UserId` as the same opaque string,
171    /// so `fragment_message` can look up MTUs by `message.recipient`. If a
172    /// future refactor ever introduces UserId ↔ device-id translation, this
173    /// map MUST be rekeyed at the same time or `fragment_message` will
174    /// silently fall back to [`BLE_MAX_FRAGMENT_SIZE`] for every peer with
175    /// no compile-time warning.
176    peer_mtus: Arc<Mutex<HashMap<String, usize>>>,
177    /// Count of undersized MTU reports received from the platform layer.
178    ///
179    /// Incremented every time [`Self::set_peer_mtu`] takes the reject
180    /// branch (payload < [`BLE_MAX_FRAGMENT_SIZE`]). A non-zero value in
181    /// production is a signal that the "modern BLE controllers never
182    /// negotiate below 185" assumption is being violated on some link —
183    /// any such peer is silently falling back to the 185-byte floor,
184    /// which is *higher* than the real usable payload, and every
185    /// fragment written to it will be dropped by the controller. Expose
186    /// via [`Self::undersized_mtu_reports`] so fleet telemetry can
187    /// surface it.
188    undersized_mtu_reports: Arc<AtomicU64>,
189    /// Count of [`Self::fragment_message`] calls that fell back to the
190    /// [`BLE_MAX_FRAGMENT_SIZE`] floor **and whose recipient is still a
191    /// registered direct BLE peer** at the moment of fallback.
192    ///
193    /// In healthy operation this should remain **zero**. Both platforms
194    /// push the MTU BEFORE announcing the peer to the protocol layer
195    /// (iOS: `bleSetPeerMtu` precedes `blePeerDiscovered` in the
196    /// DEVICE_ID read handler; Android: the facade flushes the staged
197    /// MTU via `onDeviceIdResolved` before `blePeerDiscovered` fires),
198    /// so by the time any fragmenting send can reach a peer the MTU
199    /// entry is already on file. The `peers.contains_key` gate on the
200    /// increment deliberately excludes the benign TOCTOU where
201    /// `send()` enqueues while the peer is live and a concurrent
202    /// `on_peer_lost` drops both maps before `get_next_fragment` pops
203    /// the queued message — that race produces fragments to a dead
204    /// link, not a broken invariant, and counting it would drown out
205    /// the real signal every time a peer disconnects with in-flight
206    /// sends.
207    ///
208    /// A non-zero value therefore means one of two things: the
209    /// MTU-before-discover ordering invariant regressed on one of the
210    /// platforms, or the `recipient → device_id` keying contract
211    /// broke (e.g. a future multi-hop refactor where
212    /// `message.recipient` is the final destination rather than the
213    /// direct peer). Either way: every fragmenting send to live peers
214    /// is silently regressing to 185 bytes with no compile-time
215    /// error. Surface via [`Self::fragment_fallback_count`] so
216    /// production alerts fire the first time the invariant breaks.
217    fragment_fallback_count: Arc<AtomicU64>,
218    /// Count of [`Transport::send`] calls that returned
219    /// [`PeerNotReachable`](crate::Error::PeerNotReachable) for a recipient that
220    /// was **not** among the connected BLE peers, *while other peers WERE
221    /// connected* (the peers map was non-empty).
222    ///
223    /// "No BLE peers at all" is the ordinary offline case and is **not** counted —
224    /// DORS escalating that send to Internet is exactly right. A non-empty map
225    /// missing only this recipient is the suspicious case: the device is meshing
226    /// with someone, just not the addressed id. The dominant cause is a
227    /// `recipient` ↔ `ble_peer_discovered` device-id **keying mismatch** (e.g. an
228    /// app addressing a message by a user id that differs from the advertised mesh
229    /// id), which makes a physically-in-range peer look unreachable and silently
230    /// routes the message to Internet. Surfacing it turns a silent misroute into
231    /// an observable signal. Exposed via [`Self::recipient_not_among_peers_count`].
232    recipient_not_among_peers_count: Arc<AtomicU64>,
233    /// Platform callback invoked when new fragments are available to send.
234    /// Called from `send()` after enqueueing — the platform layer should
235    /// respond by calling `get_next_fragment()` and performing the BLE write.
236    on_fragments_available: SharedCallback,
237    /// Optional callback fired when a fragment assembly is evicted from the
238    /// reassembly cache due to capacity pressure. Wired by the protocol layer
239    /// to emit `Event::FragmentAssemblyEvicted`.
240    eviction_callback: Arc<Mutex<Option<FragmentEvictionCallback>>>,
241}
242
243/// Queues a message for the bridge, a delivery acknowledgement ahead of every
244/// queued data frame (behind earlier acknowledgements, so they keep their
245/// order).
246///
247/// An acknowledgement is what stops its sender's retry timer. Queued behind
248/// data on a link carrying less than it is offered, it arrived after that
249/// timer had fired, and each resend it provoked cost the receiver another
250/// acknowledgement on the same saturated link: in the 2026-10-19 volume test
251/// the acknowledgement round trip reached 196 seconds against a 10-second
252/// timeout. The fragments of a message already being written are not
253/// overtaken; they are past this queue.
254fn enqueue_send(queue: &mut VecDeque<(String, Message)>, entry: (String, Message)) {
255    let is_ack = |message: &Message| message.metadata.contains_key(ACK_FOR_KEY);
256    if is_ack(&entry.1) {
257        let at = queue
258            .iter()
259            .position(|(_, queued)| !is_ack(queued))
260            .unwrap_or(queue.len());
261        queue.insert(at, entry);
262    } else {
263        queue.push_back(entry);
264    }
265}
266
267impl BleTransport {
268    /// Creates a new BLE transport.
269    pub fn new(device_id: impl Into<String>) -> Self {
270        Self {
271            device_id: device_id.into(),
272            status: Arc::new(Mutex::new(TransportStatus::Unavailable)),
273            peers: Arc::new(Mutex::new(HashMap::new())),
274            receive_queue: Arc::new(Mutex::new(VecDeque::new())),
275            send_queue: Arc::new(Mutex::new(VecDeque::new())),
276            pending_fragments: Arc::new(Mutex::new(VecDeque::new())),
277            metrics: Arc::new(Mutex::new(TransportMetrics::default())),
278            platform_handle: Arc::new(Mutex::new(None)),
279            fragment_buffers: Arc::new(Mutex::new(HashMap::new())),
280            peer_mtus: Arc::new(Mutex::new(HashMap::new())),
281            undersized_mtu_reports: Arc::new(AtomicU64::new(0)),
282            fragment_fallback_count: Arc::new(AtomicU64::new(0)),
283            recipient_not_among_peers_count: Arc::new(AtomicU64::new(0)),
284            on_fragments_available: Arc::new(Mutex::new(None)),
285            eviction_callback: Arc::new(Mutex::new(None)),
286        }
287    }
288
289    /// Registers a callback that fires when new outgoing fragments become available.
290    ///
291    /// The platform layer (Swift/Kotlin) implements this to wake up and call
292    /// `get_next_fragment()` instead of polling on a timer.
293    pub fn set_on_fragments_available(&self, callback: Arc<dyn Fn() + Send + Sync>) {
294        *self.on_fragments_available.lock_or_recover() = Some(callback);
295    }
296
297    /// Registers a callback that fires when a fragment assembly is evicted
298    /// from the reassembly cache due to capacity pressure.
299    pub fn set_fragment_eviction_callback(&self, callback: Option<FragmentEvictionCallback>) {
300        *self.eviction_callback.lock_or_recover() = callback;
301    }
302
303    /// Notifies the platform that fragments are ready to send.
304    fn notify_fragments_available(&self) {
305        let callback = self.on_fragments_available.lock_or_recover().clone();
306        if let Some(cb) = callback {
307            cb();
308        }
309    }
310
311    /// Returns the maximum fragment payload to use for a given peer, falling
312    /// back to [`BLE_MAX_FRAGMENT_SIZE`] when no negotiated value is on file.
313    pub fn peer_mtu(&self, peer_id: &str) -> usize {
314        self.peer_mtus
315            .lock_or_recover()
316            .get(peer_id)
317            .copied()
318            .unwrap_or(BLE_MAX_FRAGMENT_SIZE)
319    }
320
321    /// Monotonic count of undersized MTU reports since transport creation.
322    ///
323    /// A non-zero value means at least one peer reported a max usable
324    /// payload below [`BLE_MAX_FRAGMENT_SIZE`] and is now being served
325    /// the fallback floor — which is *higher* than the real link
326    /// capacity, so outbound writes to that peer are being dropped by
327    /// the controller. Surface in dashboards to detect controllers that
328    /// violate the target-platform assumption.
329    pub fn undersized_mtu_reports(&self) -> u64 {
330        self.undersized_mtu_reports.load(Ordering::Relaxed)
331    }
332
333    /// Monotonic count of [`Self::fragment_message`] calls that sized
334    /// against [`BLE_MAX_FRAGMENT_SIZE`] because no per-peer MTU had
335    /// been recorded for the recipient.
336    ///
337    /// A small steady-state value is normal (one-shot window between
338    /// `blePeerDiscovered` and `bleSetPeerMtu` for each new peer). A
339    /// sustained non-trivial rate indicates the keying contract
340    /// between `message.recipient` and the per-peer MTU map has
341    /// broken — at which point every fragmenting send is silently
342    /// regressing to the 185-byte floor. Surface in dashboards.
343    pub fn fragment_fallback_count(&self) -> u64 {
344        self.fragment_fallback_count.load(Ordering::Relaxed)
345    }
346
347    /// Monotonic count of [`Transport::send`] calls whose recipient was not among
348    /// the connected BLE peers **while other peers were connected**.
349    ///
350    /// Zero in healthy operation. A sustained non-zero value means messages to
351    /// in-range peers are being silently routed off BLE — almost always a
352    /// `recipient` ↔ `ble_peer_discovered` device-id keying mismatch in the host
353    /// app. Surface in dashboards alongside [`Self::fragment_fallback_count`].
354    pub fn recipient_not_among_peers_count(&self) -> u64 {
355        self.recipient_not_among_peers_count.load(Ordering::Relaxed)
356    }
357
358    /// Sets the platform-specific handle.
359    ///
360    /// This is called by the platform implementation to store its context.
361    pub fn set_platform_handle(&self, handle: usize) {
362        crate::common::set_platform_handle(&self.platform_handle, handle);
363    }
364
365    /// Gets the platform-specific handle.
366    pub fn platform_handle(&self) -> Option<usize> {
367        crate::common::platform_handle(&self.platform_handle)
368    }
369
370    /// Gets the local device ID.
371    pub fn device_id(&self) -> &str {
372        &self.device_id
373    }
374
375    /// Called when a peer device is discovered.
376    pub fn on_peer_discovered(&self, peer: PeerDevice) {
377        let mut peers = self.peers.lock_or_recover();
378        peers.insert(peer.device_id.clone(), peer);
379    }
380
381    /// Called when a peer device is lost.
382    ///
383    /// Also drops any stored per-peer MTU so a reconnect cannot observe a
384    /// stale value from the previous link. Platforms may still call
385    /// [`Self::clear_peer_mtu`] directly for mid-link renegotiation paths.
386    pub fn on_peer_lost(&self, device_id: &str) {
387        self.peers.lock_or_recover().remove(device_id);
388        self.peer_mtus.lock_or_recover().remove(device_id);
389    }
390
391    /// Called when a message is received from a peer.
392    pub fn on_message_received(&self, message: Message) {
393        crate::common::on_message_received(&self.receive_queue, message);
394    }
395
396    /// Like [`on_message_received`](Self::on_message_received), but attaches a
397    /// transport-verified `peer_id` to the message.
398    ///
399    /// # Parameters
400    ///
401    /// * `peer_id` — The **user-level identifier** (i.e., the remote peer's
402    ///   `UserId` string) that the transport layer has authenticated for this
403    ///   connection. This is **not** the raw transport address (MAC, BLE device
404    ///   UUID, etc.). The protocol layer uses this value to verify that
405    ///   `message.sender` matches the physical peer that delivered it.
406    pub fn on_message_received_from(&self, message: Message, peer_id: String) {
407        crate::common::on_message_received_from(&self.receive_queue, message, peer_id);
408    }
409
410    /// Gets all discovered peers.
411    pub fn get_peers(&self) -> Vec<PeerDevice> {
412        let peers = self.peers.lock_or_recover();
413        peers.values().cloned().collect()
414    }
415
416    /// Gets a specific peer by device ID.
417    pub fn get_peer(&self, device_id: &str) -> Option<PeerDevice> {
418        let peers = self.peers.lock_or_recover();
419        peers.get(device_id).cloned()
420    }
421
422    /// Updates transport metrics.
423    pub fn update_metrics(&self, metrics: TransportMetrics) {
424        *self.metrics.lock_or_recover() = metrics;
425    }
426
427    fn update_queue_metric(&self) {
428        let send_len = self.send_queue.lock_or_recover().len();
429        let fragment_len = self.pending_fragments.lock_or_recover().len();
430        let mut metrics = self.metrics.lock_or_recover();
431        metrics.queue_depth = send_len + fragment_len;
432        let heuristic_capacity = 50_f32;
433        metrics.congestion = ((metrics.queue_depth as f32) / heuristic_capacity).clamp(0.0, 1.0);
434    }
435
436    fn record_latency(&self, latency_ms: u128) {
437        let value = latency_ms.min(u128::from(u32::MAX)) as u32;
438        let mut metrics = self.metrics.lock_or_recover();
439        metrics.latency_ms = Some(match metrics.latency_ms {
440            Some(existing) => {
441                let ema = (existing as f32 * 0.7) + (value as f32 * 0.3);
442                ema as u32
443            }
444            None => value,
445        });
446    }
447
448    fn cleanup_fragment_buffers(&self) {
449        let mut buffers = self.fragment_buffers.lock_or_recover();
450        let now = SystemTime::now();
451        let mut expired = Vec::new();
452
453        for (message_id, assembly) in buffers.iter() {
454            // Idle timeout: evict only buffers with no fragment received within
455            // the window (measured from last_seen, not started_at), so a slow
456            // but still-arriving message is never torn mid-assembly.
457            if now
458                .duration_since(assembly.last_seen)
459                .unwrap_or_else(|_| StdDuration::from_secs(0))
460                > StdDuration::from_secs(BLE_FRAGMENT_TIMEOUT_SECS)
461            {
462                expired.push(message_id.clone());
463            }
464        }
465
466        for message_id in expired {
467            // Not a send outcome: see the `metrics` field (#557).
468            buffers.remove(&message_id);
469            tracing::debug!(
470                message_id = %message_id,
471                "Dropped expired BLE fragment assembly"
472            );
473        }
474    }
475
476    /// Gets current queue depth for metrics.
477    pub fn get_queue_depth(&self) -> usize {
478        let send_len = self.send_queue.lock_or_recover().len();
479        let fragment_len = self.pending_fragments.lock_or_recover().len();
480        send_len + fragment_len
481    }
482
483    /// Processes the send queue (to be called by platform implementation).
484    pub fn dequeue_send(&self) -> Option<(String, Message)> {
485        let result = {
486            let mut queue = self.send_queue.lock_or_recover();
487            queue.pop_front()
488        };
489
490        if result.is_some() {
491            self.update_queue_metric();
492        }
493
494        result
495    }
496
497    /// Checks if there are messages to send.
498    pub fn has_pending_sends(&self) -> bool {
499        let queue = self.send_queue.lock_or_recover();
500        !queue.is_empty()
501    }
502
503    /// Serializes a message to bytes (JSON).
504    pub fn serialize_message(&self, message: &Message) -> Result<Vec<u8>> {
505        crate::common::serialize_message_with(message)
506    }
507
508    /// Fragments a message into chunks suitable for BLE transmission.
509    ///
510    /// The fragment payload size is chosen from the recipient's negotiated
511    /// MTU — derived from `message.recipient`, which matches the key the
512    /// platform layer uses when reporting negotiated values via
513    /// [`Self::set_peer_mtu`]. Falls back to [`BLE_MAX_FRAGMENT_SIZE`] when
514    /// no MTU has been reported yet.
515    ///
516    /// **Race window with [`Self::set_peer_mtu`]:** the MTU is read once
517    /// at the top of this function and the lock is released before the
518    /// fragment loop runs. A concurrent `set_peer_mtu` landing between
519    /// the read and the emit produces a single batch of fragments sized
520    /// against the stale value. This is harmless on the common upward-
521    /// renegotiation path (old MTU was ≤ new MTU, so the old-sized
522    /// fragments still fit). The only hazard is a *downward* mid-link
523    /// renegotiation that stays above the floor (e.g. 400 → 200), where
524    /// the in-flight batch may exceed the new link capacity. We accept
525    /// that window rather than widening the critical section because
526    /// (a) BLE controllers almost never renegotiate down, (b) the
527    /// undersized-reject branch already covers the common "drop to
528    /// floor" case, and (c) the reliability layer retransmits any
529    /// dropped fragments under the new MTU — so recovery is automatic.
530    /// Verified: `RetryEntry` in
531    /// `crates/offline-protocol-reliability/src/retry_queue.rs`
532    /// stores the full `Message`, not a cached fragment vector, so
533    /// retries re-enter `fragment_message` under the current
534    /// `peer_mtus` value rather than replaying stale chunks.
535    /// If production telemetry ever shows sustained fragment loss on
536    /// downward renegotiations, snapshot-then-revalidate at the pre-
537    /// send boundary; do not hold `peer_mtus` across the whole loop,
538    /// which would serialise every fragmenting send on a single peer.
539    pub fn fragment_message(&self, message: &Message) -> Result<Vec<Vec<u8>>> {
540        self.fragment_message_for(message.recipient.as_str(), message)
541    }
542
543    /// [`Self::fragment_message`], but sized against an explicit **physical
544    /// peer** rather than the message recipient.
545    ///
546    /// The two differ only for a forwarded frame, where the link this batch is
547    /// about to cross belongs to `peer_id` and the recipient is some node
548    /// further along. MTU is a property of that link, so it must be looked up
549    /// by the peer actually being written to — sizing a forwarded frame
550    /// against the final recipient's MTU would apply a stranger's link
551    /// parameters (or, more often, silently fall back to the floor because the
552    /// recipient has no entry at all).
553    pub fn fragment_message_for(&self, peer_id: &str, message: &Message) -> Result<Vec<Vec<u8>>> {
554        let message_bytes = self.serialize_message(message)?;
555
556        let recipient = peer_id;
557        // Look up the per-peer MTU. On a miss, the `peers` map tells
558        // us whether this is a keying-contract break (recipient is
559        // still a registered direct peer but has no MTU on file — the
560        // platform ordering invariant must have regressed, or
561        // `recipient` no longer matches the direct-peer device_id
562        // used to key `peer_mtus`) or the benign send/on_peer_lost
563        // race (recipient was live when `send()` validated and
564        // enqueued, but `on_peer_lost` dropped both maps in lockstep
565        // before `get_next_fragment` popped the queued message —
566        // fragments in that race go to a dead link, not a broken
567        // invariant). Only the first case increments
568        // `fragment_fallback_count`; counting the second would drown
569        // out the real signal on every disconnect with in-flight
570        // sends.
571        //
572        // The peers-lock acquisition happens only on the miss branch,
573        // so the healthy hot path (MTU on file) pays just the
574        // peer_mtus lock. With the platform-side MTU-before-discover
575        // ordering (see commit ac8cce8), the miss branch should
576        // effectively never execute for a live peer.
577        //
578        // **Lock-ordering note:** the peer_mtus lookup is hoisted into
579        // its own statement so the MutexGuard drops before the miss
580        // branch reaches for `peers.lock()`. Under Rust 2021 temporary-
581        // scope rules, embedding the lookup in the `match` scrutinee
582        // would keep the peer_mtus guard alive across the arm bodies
583        // and establish an implicit `peer_mtus -> peers` ordering held
584        // by a single thread — safe today (no path holds `peers` while
585        // taking `peer_mtus`) but one refactor away from an AB/BA
586        // deadlock. Keep this as two statements.
587        let cached_mtu = self.peer_mtus.lock_or_recover().get(recipient).copied();
588        let mtu = match cached_mtu {
589            Some(mtu) => mtu,
590            None => {
591                let is_direct_peer = self.peers.lock_or_recover().contains_key(recipient);
592                if is_direct_peer {
593                    self.fragment_fallback_count.fetch_add(1, Ordering::Relaxed);
594                    // Debug-only: surface the contract break with a
595                    // warn in development builds so a refactor that
596                    // regresses the ordering trips immediately. Release
597                    // builds rely on the counter + dashboard alert.
598                    #[cfg(debug_assertions)]
599                    tracing::warn!(
600                        peer = %recipient,
601                        "ble: fragment_message found no MTU for a registered direct peer — \
602                         MTU-before-discover ordering invariant regressed, or the \
603                         recipient -> device_id keying contract broke"
604                    );
605                } else {
606                    // Benign teardown race: `send()` enqueued while
607                    // the peer was live, `on_peer_lost` dropped both
608                    // maps before `get_next_fragment` got here. The
609                    // fragments will be produced under the floor and
610                    // routed to a now-absent peer — the reliability
611                    // layer will retry under whatever transport is
612                    // available next. Not a contract break.
613                    #[cfg(debug_assertions)]
614                    tracing::debug!(
615                        peer = %recipient,
616                        "ble: fragment_message for recipient no longer in direct-peer map — \
617                         benign send/on_peer_lost race, not counted"
618                    );
619                }
620                BLE_MAX_FRAGMENT_SIZE
621            }
622        };
623        tracing::trace!(
624            peer = %recipient,
625            mtu,
626            "ble: selected fragment payload size"
627        );
628        let message_id = message.id.as_str();
629        let message_id_bytes = message_id.as_bytes();
630        if message_id_bytes.len() > u8::MAX as usize {
631            return Err(crate::Error::Other("Message ID too long".to_string()));
632        }
633
634        // Ensure fragment payload fits within MTU once headers are applied
635        let header_overhead = FRAGMENT_HEADER_FIXED + message_id_bytes.len();
636        if header_overhead >= mtu {
637            return Err(crate::Error::Other(
638                "MTU too small for fragment header".to_string(),
639            ));
640        }
641
642        let max_fragment_payload = mtu - header_overhead;
643        let total_fragments = message_bytes.len().div_ceil(max_fragment_payload);
644        if total_fragments == 0 {
645            return Err(crate::Error::Other("Empty message".to_string()));
646        }
647        if total_fragments > BLE_MAX_FRAGMENT_COUNT {
648            return Err(crate::Error::Other(
649                "Message would require too many BLE fragments".to_string(),
650            ));
651        }
652
653        if total_fragments > u16::MAX as usize {
654            return Err(crate::Error::Other(
655                "Message too large to fragment".to_string(),
656            ));
657        }
658
659        let mut fragments = Vec::with_capacity(total_fragments);
660        for (i, chunk) in message_bytes.chunks(max_fragment_payload).enumerate() {
661            let encoded =
662                encode_fragment(message_id_bytes, i as u16, total_fragments as u16, chunk)?;
663            fragments.push(encoded);
664        }
665
666        Ok(fragments)
667    }
668
669    /// Processes an incoming fragment and reassembles if complete.
670    ///
671    /// Returns Ok(Some(Message)) if message is complete, Ok(None) if more fragments needed.
672    pub fn process_fragment(&self, fragment_data: &[u8]) -> Result<Option<Message>> {
673        self.cleanup_fragment_buffers();
674
675        // Decode fragment from binary format
676        let fragment = decode_fragment(fragment_data)?;
677
678        // Receive-side parity with the send path (see `fragment_message`): a
679        // peer must not be able to declare more fragments than we would ever
680        // produce ourselves. Rejected before any buffer is allocated, so a
681        // fabricated `total_fragments` (a u16, up to 65_535) costs nothing.
682        if fragment.total_fragments as usize > BLE_MAX_FRAGMENT_COUNT {
683            return Err(crate::Error::Other(
684                "Fragment count exceeds maximum".to_string(),
685            ));
686        }
687
688        // Every index must fall inside the declared range. The count cap above
689        // bounds how many fragments a message may declare, but nothing stops a
690        // peer from declaring a small `total_fragments` and then stuffing the
691        // assembly map with up to 65_536 *distinct* out-of-range indices —
692        // `buffered_bytes` only sees payload bytes, so tiny/empty fragments
693        // bloat the HashMap with per-entry overhead the size bound never
694        // catches. Rejecting `index >= total` closes that hole and, since no
695        // `u16` index can be `< 0`, also rejects `total_fragments == 0` (an
696        // assembly that could otherwise never complete and never free).
697        if fragment.fragment_index >= fragment.total_fragments {
698            return Err(crate::Error::Other(
699                "Fragment index out of range".to_string(),
700            ));
701        }
702
703        if fragment.total_fragments == 1 {
704            if fragment.data.len() > DEFAULT_MAX_MESSAGE_SIZE {
705                return Err(crate::Error::MessageTooLarge(
706                    fragment.data.len(),
707                    DEFAULT_MAX_MESSAGE_SIZE,
708                ));
709            }
710            return Ok(Some(self.deserialize_message(&fragment.data)?));
711        }
712
713        let mut completed_payload: Option<Vec<u8>> = None;
714        let mut assembly_started_at: Option<SystemTime> = None;
715        // Collected outside the lock to avoid holding fragment_buffers while
716        // invoking the callback (which may acquire shared_state).
717        let mut eviction_info: Option<FragmentEvictionInfo> = None;
718
719        {
720            // Multi-fragment message - add to reassembly buffer
721            let mut buffers = self.fragment_buffers.lock_or_recover();
722            let now = SystemTime::now();
723
724            if !buffers.contains_key(&fragment.message_id)
725                && buffers.len() >= BLE_MAX_FRAGMENT_ASSEMBLIES
726            {
727                // Priority-based eviction: prefer evicting assemblies with less progress
728                // rather than just the oldest. This preserves near-complete assemblies.
729                if let Some((evict_id, completion_percent)) = buffers
730                    .iter()
731                    .min_by(|(_, a), (_, b)| {
732                        let priority_a = a.eviction_priority(now);
733                        let priority_b = b.eviction_priority(now);
734                        priority_a
735                            .partial_cmp(&priority_b)
736                            .unwrap_or(std::cmp::Ordering::Equal)
737                    })
738                    .map(|(id, assembly)| {
739                        let pct = (assembly.completion_ratio() * 100.0)
740                            .round()
741                            .clamp(0.0, 100.0) as u8;
742                        (id.clone(), pct)
743                    })
744                {
745                    tracing::debug!(
746                        message_id = %evict_id,
747                        completion_percent,
748                        "Evicting fragment assembly to make room (priority-based)"
749                    );
750
751                    eviction_info = Some(FragmentEvictionInfo {
752                        message_id: evict_id.clone(),
753                        completion_percent,
754                    });
755
756                    buffers.remove(&evict_id);
757                }
758            }
759
760            // Validate the fragment and compute the projected buffered size
761            // with a short read-only borrow, so the overflow path below can
762            // drop the whole assembly without a live &mut borrow fighting the
763            // removal.
764            let projected_bytes = {
765                let existing = buffers.get(&fragment.message_id);
766
767                // Every fragment of a message must agree on the total count.
768                if let Some(a) = existing {
769                    if a.total_fragments != fragment.total_fragments {
770                        return Err(crate::Error::Other(format!(
771                            "Fragment count mismatch: expected {}, got {}",
772                            a.total_fragments, fragment.total_fragments
773                        )));
774                    }
775                }
776
777                // saturating_sub keeps a duplicate fragment index (a retransmit
778                // overwriting an already-counted payload) from inflating the total.
779                let prior_total = existing.map(|a| a.buffered_bytes).unwrap_or(0);
780                let prior_len = existing
781                    .and_then(|a| a.fragments.get(&fragment.fragment_index))
782                    .map(Vec::len)
783                    .unwrap_or(0);
784                prior_total.saturating_sub(prior_len) + fragment.data.len()
785            };
786
787            // Enforce the reassembled-size bound incrementally, before the
788            // assembly can complete. A message whose fragments would exceed the
789            // limit is unrecoverable, so drop the whole assembly to free its
790            // buffered bytes immediately rather than parking them until the
791            // idle timeout.
792            if projected_bytes > DEFAULT_MAX_MESSAGE_SIZE {
793                buffers.remove(&fragment.message_id);
794                return Err(crate::Error::MessageTooLarge(
795                    projected_bytes,
796                    DEFAULT_MAX_MESSAGE_SIZE,
797                ));
798            }
799
800            // Get or create assembly buffer
801            let assembly = buffers
802                .entry(fragment.message_id.clone())
803                .or_insert_with(|| FragmentAssembly {
804                    total_fragments: fragment.total_fragments,
805                    fragments: HashMap::new(),
806                    started_at: now,
807                    last_seen: now,
808                    buffered_bytes: 0,
809                });
810
811            assembly.buffered_bytes = projected_bytes;
812            assembly
813                .fragments
814                .insert(fragment.fragment_index, fragment.data);
815            // Idle-timeout anchor: refresh on every fragment so an actively
816            // arriving multi-fragment message keeps its buffer alive past the
817            // BLE_FRAGMENT_TIMEOUT_SECS window measured from the first fragment.
818            // Security note: a peer dribbling one fragment per sub-timeout interval
819            // can therefore hold a partial assembly open indefinitely (vs. a hard
820            // first-fragment deadline). The exposure is bounded by the
821            // BLE_MAX_FRAGMENT_ASSEMBLIES cap + low-progress priority eviction, and
822            // is the deliberate tradeoff for not tearing a slow-but-legitimate
823            // multi-fragment message mid-flight.
824            assembly.last_seen = now;
825
826            // Check if complete
827            if assembly.fragments.len() == assembly.total_fragments as usize {
828                // Reassemble message
829                let mut complete_data = Vec::new();
830                for i in 0..assembly.total_fragments {
831                    if let Some(data) = assembly.fragments.get(&i) {
832                        complete_data.extend_from_slice(data);
833                    } else {
834                        return Err(crate::Error::Other(format!("Missing fragment {}", i)));
835                    }
836                }
837
838                assembly_started_at = Some(assembly.started_at);
839                buffers.remove(&fragment.message_id);
840                completed_payload = Some(complete_data);
841            }
842        }
843
844        // Fire eviction callback outside the fragment_buffers lock to avoid
845        // lock ordering issues (callback may acquire shared_state). The
846        // callback Arc is cloned out of its own mutex too, so a callback
847        // that re-enters the transport (e.g. set_fragment_eviction_callback)
848        // cannot self-deadlock.
849        if let Some(info) = eviction_info {
850            let cb = self.eviction_callback.lock_or_recover().clone();
851            if let Some(cb) = cb {
852                cb(info);
853            }
854        }
855
856        if let Some(payload) = completed_payload {
857            if payload.len() > DEFAULT_MAX_MESSAGE_SIZE {
858                return Err(crate::Error::MessageTooLarge(
859                    payload.len(),
860                    DEFAULT_MAX_MESSAGE_SIZE,
861                ));
862            }
863
864            let start = assembly_started_at.unwrap_or_else(SystemTime::now);
865            let latency = SystemTime::now()
866                .duration_since(start)
867                .unwrap_or_else(|_| StdDuration::from_millis(0))
868                .as_millis();
869            self.record_latency(latency);
870
871            // Deserialize complete message
872            let message = self.deserialize_message(&payload)?;
873            return Ok(Some(message));
874        }
875
876        // More fragments needed
877        Ok(None)
878    }
879
880    /// Like [`Transport::on_fragment_received`], but attaches a
881    /// transport-verified `peer_id` to the reassembled message.
882    ///
883    /// # Parameters
884    ///
885    /// * `peer_id` — The **user-level identifier** (i.e., the remote peer's
886    ///   `UserId` string) that the transport layer has authenticated for this
887    ///   connection. This is **not** the raw transport address (MAC, BLE device
888    ///   UUID, etc.). The protocol layer uses this value to verify that
889    ///   `message.sender` matches the physical peer that delivered it.
890    pub fn on_fragment_received_from(&self, fragment_data: Vec<u8>, peer_id: String) -> Result<()> {
891        match self.process_fragment(&fragment_data) {
892            Ok(Some(mut message)) => {
893                message.set_transport_peer_id(peer_id)?;
894                let msg_id = message.id.clone();
895                let mut queue = self.receive_queue.lock_or_recover();
896                queue.push_back(message);
897                tracing::debug!(
898                    message_id = %msg_id,
899                    "Complete message assembled from fragments (with peer identity)"
900                );
901                Ok(())
902            }
903            Ok(None) => {
904                tracing::debug!("Fragment received, more needed for complete message");
905                Ok(())
906            }
907            Err(e) => {
908                tracing::warn!(error = %e, "Error processing fragment, dropping bad fragment");
909                Ok(())
910            }
911        }
912    }
913
914    /// Re-queues a fragment at the front of the pending queue (used when platform send fails).
915    pub fn requeue_fragment(&self, recipient: &str, fragment_data: Vec<u8>) {
916        {
917            let mut pending = self.pending_fragments.lock_or_recover();
918            pending.push_front((recipient.to_string(), fragment_data));
919        }
920
921        self.update_queue_metric();
922    }
923}
924
925fn encode_fragment(
926    message_id: &[u8],
927    fragment_index: u16,
928    total_fragments: u16,
929    data: &[u8],
930) -> Result<Vec<u8>> {
931    if data.len() > u16::MAX as usize {
932        return Err(crate::Error::Other(
933            "Fragment payload too large".to_string(),
934        ));
935    }
936
937    let mut encoded = Vec::with_capacity(FRAGMENT_HEADER_FIXED + message_id.len() + data.len());
938    encoded.extend_from_slice(&FRAGMENT_MAGIC);
939    encoded.push(FRAGMENT_VERSION);
940    encoded.push(message_id.len() as u8);
941    encoded.extend_from_slice(message_id);
942    encoded.extend_from_slice(&fragment_index.to_le_bytes());
943    encoded.extend_from_slice(&total_fragments.to_le_bytes());
944    encoded.extend_from_slice(&(data.len() as u16).to_le_bytes());
945    encoded.extend_from_slice(data);
946    Ok(encoded)
947}
948
949fn decode_fragment(fragment_data: &[u8]) -> Result<DecodedFragment> {
950    if fragment_data.len() < FRAGMENT_HEADER_FIXED {
951        return Err(crate::Error::Other("Fragment too short".to_string()));
952    }
953
954    if fragment_data[0..2] != FRAGMENT_MAGIC {
955        return Err(crate::Error::Other("Invalid fragment magic".to_string()));
956    }
957
958    let version = fragment_data[2];
959    if version != FRAGMENT_VERSION {
960        return Err(crate::Error::Other(format!(
961            "Unsupported fragment version {}",
962            version
963        )));
964    }
965
966    let id_len = fragment_data[3] as usize;
967    let header_len = FRAGMENT_HEADER_FIXED + id_len;
968    if fragment_data.len() < header_len {
969        return Err(crate::Error::Other("Fragment truncated (id)".to_string()));
970    }
971
972    let mut offset = 4;
973    let message_id_bytes = &fragment_data[offset..offset + id_len];
974    offset += id_len;
975
976    let message_id = String::from_utf8(message_id_bytes.to_vec())
977        .map_err(|_| crate::Error::Other("Invalid UTF-8 in message ID".to_string()))?;
978
979    if fragment_data.len() < offset + 6 {
980        return Err(crate::Error::Other(
981            "Fragment truncated (header)".to_string(),
982        ));
983    }
984
985    let fragment_index = u16::from_le_bytes(
986        fragment_data[offset..offset + 2]
987            .try_into()
988            .map_err(|_| crate::Error::Other("Fragment truncated (index)".to_string()))?,
989    );
990    offset += 2;
991    let total_fragments = u16::from_le_bytes(
992        fragment_data[offset..offset + 2]
993            .try_into()
994            .map_err(|_| crate::Error::Other("Fragment truncated (total)".to_string()))?,
995    );
996    offset += 2;
997    let data_len = u16::from_le_bytes(
998        fragment_data[offset..offset + 2]
999            .try_into()
1000            .map_err(|_| crate::Error::Other("Fragment truncated (length)".to_string()))?,
1001    ) as usize;
1002    offset += 2;
1003
1004    if fragment_data.len() < offset + data_len {
1005        return Err(crate::Error::Other("Fragment truncated (data)".to_string()));
1006    }
1007
1008    let data = fragment_data[offset..offset + data_len].to_vec();
1009
1010    Ok(DecodedFragment {
1011        message_id,
1012        fragment_index,
1013        total_fragments,
1014        data,
1015    })
1016}
1017
1018impl Transport for BleTransport {
1019    fn as_any(&self) -> &dyn std::any::Any {
1020        self
1021    }
1022
1023    fn transport_type(&self) -> TransportType {
1024        TransportType::BLE
1025    }
1026
1027    fn status(&self) -> TransportStatus {
1028        *self.status.lock_or_recover()
1029    }
1030
1031    fn metrics(&self) -> TransportMetrics {
1032        self.metrics.lock_or_recover().clone()
1033    }
1034
1035    fn send(&self, message: &Message) -> Result<()> {
1036        let status = self.status();
1037
1038        if status != TransportStatus::Available {
1039            return Err(crate::Error::TransportNotAvailable(format!(
1040                "BLE transport is not available (status: {:?})",
1041                status
1042            )));
1043        }
1044
1045        let recipient = message.recipient.as_str().to_string();
1046
1047        {
1048            let peers = self.peers.lock_or_recover();
1049            if !peers.contains_key(&recipient) {
1050                // Distinguish "no BLE peers at all" (ordinary offline; escalate to
1051                // the next transport) from "meshing with other peers but not this
1052                // recipient" — the latter is the keying-mismatch signal that
1053                // silently routes an in-range peer's message to Internet. Count and
1054                // warn only the latter so the metric isn't drowned out by the
1055                // benign no-peers case. See `recipient_not_among_peers_count`.
1056                if !peers.is_empty() {
1057                    self.recipient_not_among_peers_count
1058                        .fetch_add(1, Ordering::Relaxed);
1059                    tracing::warn!(
1060                        recipient = %recipient,
1061                        connected_peers = peers.len(),
1062                        "ble: send recipient is not among the connected BLE peers while \
1063                         other peers are connected — likely a recipient↔device-id keying \
1064                         mismatch; this send will fall back off BLE"
1065                    );
1066                }
1067                return Err(crate::Error::PeerNotReachable(format!(
1068                    "BLE: no connected peer for recipient {}",
1069                    recipient
1070                )));
1071            }
1072        }
1073
1074        enqueue_send(
1075            &mut self.send_queue.lock_or_recover(),
1076            (recipient, message.clone()),
1077        );
1078
1079        self.update_queue_metric();
1080        self.notify_fragments_available();
1081
1082        Ok(())
1083    }
1084
1085    /// A message leaves this list when the bridge pulls its first fragment:
1086    /// from then on it is being written, and a timeout means what it says.
1087    fn queued_message_ids(&self) -> Vec<String> {
1088        self.send_queue
1089            .lock_or_recover()
1090            .iter()
1091            .map(|(_, message)| message.id.as_str())
1092            .collect()
1093    }
1094
1095    /// Queues `message` for a specific connected peer.
1096    ///
1097    /// Identical to [`Transport::send`] except that the queue key — which is
1098    /// both the write target for the platform bridge and the MTU key for
1099    /// fragmentation — is `peer_id` rather than `message.recipient`.
1100    fn send_to_peer(&self, peer_id: &str, message: &Message) -> Result<()> {
1101        let status = self.status();
1102
1103        if status != TransportStatus::Available {
1104            return Err(crate::Error::TransportNotAvailable(format!(
1105                "BLE transport is not available (status: {:?})",
1106                status
1107            )));
1108        }
1109
1110        {
1111            let peers = self.peers.lock_or_recover();
1112            if !peers.contains_key(peer_id) {
1113                return Err(crate::Error::PeerNotReachable(format!(
1114                    "BLE: {} is not a connected peer",
1115                    peer_id
1116                )));
1117            }
1118        }
1119
1120        enqueue_send(
1121            &mut self.send_queue.lock_or_recover(),
1122            (peer_id.to_string(), message.clone()),
1123        );
1124
1125        self.update_queue_metric();
1126        self.notify_fragments_available();
1127
1128        Ok(())
1129    }
1130
1131    /// Lists every registered direct peer.
1132    ///
1133    /// Membership is deliberately the same test [`Transport::send`] and
1134    /// [`Transport::send_to_peer`] apply — presence in `peers` — rather than
1135    /// the `PeerDevice::connected` flag. The two must agree: a peer omitted
1136    /// here but accepted by the send path is a usable link a forwarding caller
1137    /// would never try, whereas the reverse costs only a synchronous error the
1138    /// caller already handles by moving to the next neighbor.
1139    fn connected_peers(&self) -> Vec<crate::PeerLink> {
1140        if self.status() != TransportStatus::Available {
1141            return Vec::new();
1142        }
1143        self.peers
1144            .lock_or_recover()
1145            .values()
1146            .map(|peer| crate::PeerLink::with_rssi(peer.device_id.clone(), peer.rssi))
1147            .collect()
1148    }
1149
1150    fn receive(&self) -> Result<Option<Message>> {
1151        let mut queue = self.receive_queue.lock_or_recover();
1152        Ok(queue.pop_front())
1153    }
1154
1155    fn start(&self) -> Result<()> {
1156        // Set status to Available when starting
1157        // Platform can still override this via on_status_changed() if BLE is not available
1158        *self.status.lock_or_recover() = TransportStatus::Available;
1159        Ok(())
1160    }
1161
1162    fn stop(&self) -> Result<()> {
1163        // **Caller contract:** the platform callback pump must already
1164        // be quiesced before `stop()` returns. Every `set_peer_mtu` /
1165        // `on_peer_discovered` / `on_fragment_received` entry takes
1166        // `&self`, so a late callback landing after the drains below
1167        // will re-seed state into what is supposed to be an at-rest
1168        // transport. Android/iOS bindings enforce this by draining
1169        // their own GATT state before calling into the Rust stop path.
1170        *self.status.lock_or_recover() = TransportStatus::Disconnected;
1171        // Drain every per-session cache on teardown so a subsequent
1172        // start() observes no state from the prior session. Every
1173        // reconnect re-runs peer discovery, fragment reassembly, and
1174        // MTU negotiation from scratch. Keep the drains in lockstep —
1175        // a one-sided drain (e.g. clearing `peer_mtus` but leaving
1176        // `peers` intact) would let `send()` pass its peer guard while
1177        // the fragmenter silently falls back to the 185-byte floor, or
1178        // would let stale reassembly state outlive the peer it came
1179        // from. The `undersized_mtu_reports` counter is a monotonic
1180        // lifetime metric and is intentionally *not* reset here.
1181        self.peers.lock_or_recover().clear();
1182        self.peer_mtus.lock_or_recover().clear();
1183        self.fragment_buffers.lock_or_recover().clear();
1184        self.send_queue.lock_or_recover().clear();
1185        self.pending_fragments.lock_or_recover().clear();
1186        self.receive_queue.lock_or_recover().clear();
1187        self.update_queue_metric();
1188        Ok(())
1189    }
1190
1191    /// Called when connection status changes.
1192    ///
1193    /// When transitioning away from [`TransportStatus::Available`], all
1194    /// per-session state is drained so a subsequent reconnect starts clean.
1195    /// The drain mirrors [`Transport::stop`] and respects the same lockstep
1196    /// ordering (peers → peer_mtus → fragment_buffers → send_queue →
1197    /// pending_fragments → receive_queue). Monotonic lifetime counters
1198    /// (`undersized_mtu_reports`, `fragment_fallback_count`) are preserved.
1199    fn on_status_changed(&self, status: TransportStatus) {
1200        let previous = {
1201            let mut guard = self.status.lock_or_recover();
1202            let prev = *guard;
1203            *guard = status;
1204            prev
1205        };
1206
1207        if previous == TransportStatus::Available && status != TransportStatus::Available {
1208            self.peers.lock_or_recover().clear();
1209            self.peer_mtus.lock_or_recover().clear();
1210            self.fragment_buffers.lock_or_recover().clear();
1211            self.send_queue.lock_or_recover().clear();
1212            self.pending_fragments.lock_or_recover().clear();
1213            self.receive_queue.lock_or_recover().clear();
1214            self.update_queue_metric();
1215        }
1216    }
1217
1218    /// Called when raw fragment data is received from BLE (platform callback).
1219    ///
1220    /// This handles fragmentation reassembly and queues complete messages.
1221    fn on_fragment_received(&self, fragment_data: Vec<u8>) -> Result<()> {
1222        match self.process_fragment(&fragment_data) {
1223            Ok(Some(message)) => {
1224                // Message complete - queue it
1225                let mut queue = self.receive_queue.lock_or_recover();
1226                queue.push_back(message.clone());
1227                // Note: sender/recipient are intentionally not logged to protect user privacy
1228                tracing::debug!(
1229                    message_id = %message.id,
1230                    "Complete message assembled from fragments"
1231                );
1232                Ok(())
1233            }
1234            Ok(None) => {
1235                // More fragments needed
1236                tracing::debug!("Fragment received, more needed for complete message");
1237                Ok(())
1238            }
1239            Err(e) => {
1240                // Log error but don't fail - just drop bad fragment
1241                tracing::warn!(error = %e, "Error processing fragment, dropping bad fragment");
1242                Ok(())
1243            }
1244        }
1245    }
1246
1247    /// Called when raw fragment data arrives from a known peer.
1248    ///
1249    /// Delegates to the inherent
1250    /// [`BleTransport::on_fragment_received_from`], which records the peer on
1251    /// the reassembled message. An empty `peer_id` is treated as "the platform
1252    /// did not identify this link" and falls back to the anonymous path rather
1253    /// than failing the frame.
1254    fn on_fragment_received_from(&self, fragment_data: Vec<u8>, peer_id: String) -> Result<()> {
1255        if peer_id.is_empty() {
1256            return self.on_fragment_received(fragment_data);
1257        }
1258        BleTransport::on_fragment_received_from(self, fragment_data, peer_id)
1259    }
1260
1261    /// Gets the next fragment to send (for platform implementation).
1262    ///
1263    /// Returns (recipient, fragment_data) or None if no messages to send.
1264    fn get_next_fragment(&self) -> Result<Option<(String, Vec<u8>)>> {
1265        // Check for pending fragments first
1266        if let Some(fragment) = {
1267            let mut pending = self.pending_fragments.lock_or_recover();
1268            pending.pop_front()
1269        } {
1270            self.update_queue_metric();
1271            return Ok(Some(fragment));
1272        }
1273
1274        // No serialized fragments waiting – pull a fresh message from the queue
1275        let maybe_message = {
1276            let mut queue = self.send_queue.lock_or_recover();
1277            queue.pop_front()
1278        };
1279
1280        let Some((recipient, message)) = maybe_message else {
1281            self.update_queue_metric();
1282            return Ok(None);
1283        };
1284
1285        // Size against the queue key, not `message.recipient`: the key is the
1286        // peer this batch is about to be written to, which for a forwarded
1287        // frame is the next hop rather than the final recipient.
1288        let fragments = self.fragment_message_for(&recipient, &message)?;
1289
1290        if fragments.is_empty() {
1291            self.update_queue_metric();
1292            return Ok(None);
1293        }
1294
1295        {
1296            let mut pending = self.pending_fragments.lock_or_recover();
1297            for fragment in fragments {
1298                pending.push_back((recipient.clone(), fragment));
1299            }
1300        }
1301
1302        self.update_queue_metric();
1303
1304        let result = {
1305            let mut pending = self.pending_fragments.lock_or_recover();
1306            pending.pop_front()
1307        };
1308
1309        self.update_queue_metric();
1310
1311        Ok(result)
1312    }
1313
1314    /// Registers the callback that wakes the platform when outbound
1315    /// fragments become available. Same slot as
1316    /// [`BleTransport::set_on_fragments_available`] — BLE's "messages" are
1317    /// its fragments.
1318    fn set_on_messages_available(&self, callback: Arc<dyn Fn() + Send + Sync>) {
1319        self.set_on_fragments_available(callback);
1320    }
1321
1322    /// Records the maximum usable fragment payload for a peer after BLE MTU
1323    /// negotiation.
1324    ///
1325    /// The platform layer passes the already-header-adjusted value — iOS via
1326    /// `CBPeripheral.maximumWriteValueLength(for:)`, Android via
1327    /// `onMtuChanged` followed by `mtu - 3`. Accepted values are clamped to
1328    /// [`MAX_REASONABLE_BLE_PAYLOAD`] on the high end to protect the
1329    /// fragmenter from native layers reporting nonsensical sizes.
1330    ///
1331    /// **Undersized values are rejected, not clamped up.** A report below
1332    /// [`BLE_MAX_FRAGMENT_SIZE`] means the link's real usable payload is
1333    /// smaller than our fallback floor — storing a clamped-up 185 would
1334    /// cause the fragmenter to emit chunks the controller cannot transmit
1335    /// and every fragment to that peer would be silently dropped. We warn
1336    /// and drop any previously stored entry so [`Self::peer_mtu`] falls
1337    /// back to [`BLE_MAX_FRAGMENT_SIZE`]. Dropping (rather than leaving
1338    /// the prior entry in place) is load-bearing for mid-link
1339    /// renegotiation: if a peer had a stored 400 and the controller
1340    /// later renegotiates down to 20, silently keeping 400 would still
1341    /// produce writes the new link cannot honor. Self-consistent with
1342    /// the invariant that we do not operate below that floor on the
1343    /// platforms we target (both iOS and Android request the BLE-5
1344    /// maximum ATT MTU and every modern controller negotiates well
1345    /// above 188).
1346    fn set_peer_mtu(&self, peer_id: &str, max_payload: usize) {
1347        // Symmetric with the debug warn in `fragment_message`: neither
1348        // platform ever calls `set_peer_mtu` for a peer it has not
1349        // already announced via `blePeerDiscovered`, so a store with
1350        // no matching `peers` entry is either a buggy platform layer
1351        // or a broken keying contract. Debug-only to keep the hot
1352        // path free of an extra mutex acquisition in release builds.
1353        #[cfg(debug_assertions)]
1354        {
1355            if !self.peers.lock_or_recover().contains_key(peer_id) {
1356                tracing::warn!(
1357                    peer = %peer_id,
1358                    max_payload,
1359                    "ble: set_peer_mtu for unregistered peer — platform must call \
1360                     on_peer_discovered before reporting MTU, otherwise the stored \
1361                     entry will not be cleared on peer loss"
1362                );
1363            }
1364        }
1365        if max_payload < BLE_MAX_FRAGMENT_SIZE {
1366            self.undersized_mtu_reports.fetch_add(1, Ordering::Relaxed);
1367            tracing::warn!(
1368                peer = %peer_id,
1369                max_payload,
1370                floor = BLE_MAX_FRAGMENT_SIZE,
1371                "ble: undersized MTU report; dropping stored entry and falling back"
1372            );
1373            self.peer_mtus.lock_or_recover().remove(peer_id);
1374            return;
1375        }
1376        let clamped = max_payload.min(MAX_REASONABLE_BLE_PAYLOAD);
1377        tracing::debug!(
1378            peer = %peer_id,
1379            raw = max_payload,
1380            clamped,
1381            "ble: stored per-peer MTU"
1382        );
1383        self.peer_mtus
1384            .lock_or_recover()
1385            .insert(peer_id.to_string(), clamped);
1386    }
1387
1388    /// Removes any stored MTU for a peer (called on disconnect or before
1389    /// renegotiation).
1390    fn clear_peer_mtu(&self, peer_id: &str) {
1391        tracing::debug!(peer = %peer_id, "ble: cleared per-peer MTU");
1392        self.peer_mtus.lock_or_recover().remove(peer_id);
1393    }
1394}
1395
1396/// BLE transport builder for configuration.
1397pub struct BleTransportBuilder {
1398    device_id: String,
1399}
1400
1401impl BleTransportBuilder {
1402    /// Creates a new builder.
1403    pub fn new(device_id: impl Into<String>) -> Self {
1404        Self {
1405            device_id: device_id.into(),
1406        }
1407    }
1408
1409    /// Builds the BLE transport.
1410    pub fn build(self) -> BleTransport {
1411        BleTransport::new(self.device_id)
1412    }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417    use super::*;
1418    use offline_protocol_core::{AppId, Message, MessagePriority, UserId, TTL};
1419
1420    fn peer_device(id: &str) -> PeerDevice {
1421        PeerDevice {
1422            device_id: id.to_string(),
1423            address: "AA:BB:CC:DD:EE:FF".to_string(),
1424            rssi: -60,
1425            last_seen: std::time::SystemTime::now(),
1426            connected: false,
1427        }
1428    }
1429
1430    fn small_message() -> Message {
1431        Message::new(
1432            UserId::new("alice").unwrap(),
1433            UserId::new("bob").unwrap(),
1434            AppId::new("app").unwrap(),
1435            "hi",
1436        )
1437    }
1438
1439    #[test]
1440    fn send_to_peer_targets_the_hop_and_sizes_by_its_mtu() {
1441        let transport = BleTransport::new("relay");
1442        transport.start().unwrap();
1443        transport.on_peer_discovered(peer_device("carol"));
1444        // Carol's link negotiated a large MTU; bob (the recipient) is not a
1445        // peer here at all, so sizing by recipient would fall back to the floor.
1446        transport.set_peer_mtu("carol", 500);
1447
1448        let message = small_message(); // addressed to bob
1449        transport.send_to_peer("carol", &message).unwrap();
1450
1451        let (target, _) = transport
1452            .get_next_fragment()
1453            .unwrap()
1454            .expect("fragment queued for the hop");
1455        assert_eq!(target, "carol");
1456        // Sized against carol's 500-byte MTU, not the 185-byte floor: a small
1457        // message fits in exactly one fragment.
1458        assert!(transport.get_next_fragment().unwrap().is_none());
1459        assert_eq!(transport.fragment_fallback_count(), 0);
1460    }
1461
1462    #[test]
1463    fn send_to_peer_refuses_an_unconnected_peer() {
1464        let transport = BleTransport::new("relay");
1465        transport.start().unwrap();
1466        transport.on_peer_discovered(peer_device("carol"));
1467
1468        let result = transport.send_to_peer("dave", &small_message());
1469        assert!(matches!(result, Err(crate::Error::PeerNotReachable(_))));
1470    }
1471
1472    #[test]
1473    fn connected_peers_matches_what_the_send_path_accepts() {
1474        let transport = BleTransport::new("relay");
1475        transport.on_peer_discovered(peer_device("carol"));
1476
1477        // Nothing is addressable while the transport is down.
1478        assert!(transport.connected_peers().is_empty());
1479
1480        transport.start().unwrap();
1481        let links = transport.connected_peers();
1482        assert_eq!(links.len(), 1);
1483        assert_eq!(links[0].peer_id, "carol");
1484        assert_eq!(links[0].rssi, Some(-60));
1485        // Every listed link is one send_to_peer will accept.
1486        assert!(transport
1487            .send_to_peer(&links[0].peer_id, &small_message())
1488            .is_ok());
1489
1490        transport.on_peer_lost("carol");
1491        assert!(transport.connected_peers().is_empty());
1492    }
1493
1494    #[test]
1495    fn test_ble_transport_creation() {
1496        let transport = BleTransport::new("test-device");
1497        assert_eq!(transport.device_id(), "test-device");
1498        assert_eq!(transport.status(), TransportStatus::Unavailable);
1499    }
1500
1501    #[test]
1502    fn test_ble_send_when_unavailable_fails() {
1503        let transport = BleTransport::new("test-device");
1504        let msg = small_message();
1505        let result = transport.send(&msg);
1506        assert!(result.is_err());
1507        assert!(matches!(
1508            result.unwrap_err(),
1509            crate::Error::TransportNotAvailable(_)
1510        ));
1511    }
1512
1513    #[test]
1514    fn test_ble_start_stop() {
1515        let transport = BleTransport::new("test-device");
1516        transport.start().unwrap();
1517        assert_eq!(transport.status(), TransportStatus::Available);
1518        transport.stop().unwrap();
1519        assert_eq!(transport.status(), TransportStatus::Disconnected);
1520    }
1521
1522    #[test]
1523    fn test_ble_recipient_not_among_peers_count_signals_keying_mismatch() {
1524        let transport = BleTransport::new("test-device");
1525        transport.start().unwrap();
1526
1527        // No BLE peers at all: PeerNotReachable, but this is the ordinary offline
1528        // case (escalating to the next transport is correct) and must NOT be counted.
1529        assert!(matches!(
1530            transport.send(&small_message()).unwrap_err(),
1531            crate::Error::PeerNotReachable(_)
1532        ));
1533        assert_eq!(transport.recipient_not_among_peers_count(), 0);
1534
1535        // Meshing with another peer ("alice") but not the addressed recipient
1536        // ("bob"): the keying-mismatch signal — a physically-present mesh whose
1537        // ids don't match the send target. This MUST be counted.
1538        transport.on_peer_discovered(peer_device("alice"));
1539        assert!(matches!(
1540            transport.send(&small_message()).unwrap_err(),
1541            crate::Error::PeerNotReachable(_)
1542        ));
1543        assert_eq!(transport.recipient_not_among_peers_count(), 1);
1544
1545        // Recipient now connected: send succeeds and the counter does not move.
1546        transport.on_peer_discovered(peer_device("bob"));
1547        transport.send(&small_message()).unwrap();
1548        assert_eq!(transport.recipient_not_among_peers_count(), 1);
1549    }
1550
1551    #[test]
1552    fn test_ble_on_status_changed() {
1553        let transport = BleTransport::new("test-device");
1554        transport.on_status_changed(TransportStatus::Available);
1555        assert_eq!(transport.status(), TransportStatus::Available);
1556        transport.on_status_changed(TransportStatus::Error);
1557        assert_eq!(transport.status(), TransportStatus::Error);
1558    }
1559
1560    #[test]
1561    fn test_ble_on_status_changed_drains_session_state() {
1562        let transport = BleTransport::new("test-device");
1563        transport.on_status_changed(TransportStatus::Available);
1564
1565        transport.on_peer_discovered(peer_device("bob"));
1566        transport.set_peer_mtu("bob", 244);
1567        transport.send(&small_message()).unwrap();
1568
1569        // Seed a fragment buffer via on_fragment_received with a partial fragment.
1570        // Simpler: insert directly.
1571        transport.fragment_buffers.lock().unwrap().insert(
1572            "msg-1".to_string(),
1573            FragmentAssembly {
1574                total_fragments: 2,
1575                fragments: HashMap::new(),
1576                started_at: SystemTime::now(),
1577                last_seen: SystemTime::now(),
1578                buffered_bytes: 0,
1579            },
1580        );
1581
1582        // Bump a lifetime counter so we can verify it survives.
1583        transport
1584            .undersized_mtu_reports
1585            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1586
1587        // Transition away from Available.
1588        transport.on_status_changed(TransportStatus::Unavailable);
1589
1590        assert!(transport.peers.lock().unwrap().is_empty());
1591        assert!(transport.peer_mtus.lock().unwrap().is_empty());
1592        assert!(transport.fragment_buffers.lock().unwrap().is_empty());
1593        assert!(transport.send_queue.lock().unwrap().is_empty());
1594        assert!(transport.pending_fragments.lock().unwrap().is_empty());
1595        assert!(transport.receive_queue.lock().unwrap().is_empty());
1596        assert_eq!(transport.metrics.lock().unwrap().queue_depth, 0);
1597
1598        // Lifetime counters must survive.
1599        assert_eq!(
1600            transport
1601                .undersized_mtu_reports
1602                .load(std::sync::atomic::Ordering::Relaxed),
1603            1
1604        );
1605    }
1606
1607    /// A message counts as queued until the bridge pulls its first fragment.
1608    /// The sender reads this to tell "not sent yet" from "unanswered" when an
1609    /// acknowledgement times out.
1610    #[test]
1611    fn queued_message_ids_lists_a_message_until_its_first_fragment_is_pulled() {
1612        let transport = BleTransport::new("test-device");
1613        transport.on_status_changed(TransportStatus::Available);
1614        transport.on_peer_discovered(peer_device("bob"));
1615        let message = small_message();
1616        transport.send(&message).unwrap();
1617
1618        assert_eq!(transport.queued_message_ids(), vec![message.id.as_str()]);
1619        assert!(transport.get_next_fragment().unwrap().is_some());
1620        assert!(transport.queued_message_ids().is_empty());
1621    }
1622
1623    #[test]
1624    fn test_ble_on_status_changed_available_to_available_preserves_state() {
1625        let transport = BleTransport::new("test-device");
1626        transport.on_status_changed(TransportStatus::Available);
1627
1628        transport.on_peer_discovered(peer_device("bob"));
1629        transport.set_peer_mtu("bob", 244);
1630        transport.send(&small_message()).unwrap();
1631
1632        // Transition Available → Available must not clear anything.
1633        transport.on_status_changed(TransportStatus::Available);
1634
1635        assert_eq!(transport.peers.lock().unwrap().len(), 1);
1636        assert_eq!(transport.peer_mtus.lock().unwrap().len(), 1);
1637        assert!(!transport.send_queue.lock().unwrap().is_empty());
1638    }
1639
1640    #[test]
1641    fn test_ble_on_status_changed_non_available_to_non_available_no_drain() {
1642        let transport = BleTransport::new("test-device");
1643
1644        // Start at Unavailable (default), add a peer manually.
1645        transport
1646            .peers
1647            .lock()
1648            .unwrap()
1649            .insert("alice".to_string(), peer_device("alice"));
1650
1651        // Transition Unavailable → Error — should NOT drain because
1652        // previous was not Available.
1653        transport.on_status_changed(TransportStatus::Error);
1654        assert_eq!(transport.peers.lock().unwrap().len(), 1);
1655    }
1656
1657    #[test]
1658    fn test_ble_peer_mtu_defaults_to_fallback() {
1659        let transport = BleTransport::new("test-device");
1660        assert_eq!(transport.peer_mtu("unknown-peer"), BLE_MAX_FRAGMENT_SIZE);
1661    }
1662
1663    #[test]
1664    fn test_ble_set_peer_mtu_records_and_clamps() {
1665        let transport = BleTransport::new("test-device");
1666
1667        // Reasonable value stored verbatim.
1668        transport.set_peer_mtu("alice", 244);
1669        assert_eq!(transport.peer_mtu("alice"), 244);
1670
1671        // Undersized value is rejected outright — no entry is stored, so
1672        // peer_mtu falls back to BLE_MAX_FRAGMENT_SIZE rather than
1673        // recording a clamped-up 185 that the controller cannot honor.
1674        transport.set_peer_mtu("bob", 20);
1675        assert_eq!(transport.peer_mtu("bob"), BLE_MAX_FRAGMENT_SIZE);
1676        assert!(
1677            !transport.peer_mtus.lock().unwrap().contains_key("bob"),
1678            "undersized MTU report must not be stored"
1679        );
1680
1681        // Oversized value clamped down to MAX_REASONABLE_BLE_PAYLOAD.
1682        transport.set_peer_mtu("carol", 9999);
1683        assert_eq!(transport.peer_mtu("carol"), MAX_REASONABLE_BLE_PAYLOAD);
1684
1685        // Exact floor is accepted and stored.
1686        transport.set_peer_mtu("dave", BLE_MAX_FRAGMENT_SIZE);
1687        assert_eq!(transport.peer_mtu("dave"), BLE_MAX_FRAGMENT_SIZE);
1688        assert!(transport.peer_mtus.lock().unwrap().contains_key("dave"));
1689
1690        // Per-peer isolation.
1691        assert_eq!(transport.peer_mtu("alice"), 244);
1692    }
1693
1694    #[test]
1695    fn test_ble_undersized_renegotiation_drops_stored_entry() {
1696        // Regression: the reject branch must remove any prior entry, not
1697        // just return early. Otherwise a stored-400 → renegotiate-to-20
1698        // transition keeps the stale 400 and the fragmenter writes chunks
1699        // the new link cannot honor.
1700        let transport = BleTransport::new("test-device");
1701        transport.set_peer_mtu("alice", 400);
1702        assert_eq!(transport.peer_mtu("alice"), 400);
1703
1704        transport.set_peer_mtu("alice", 20);
1705        assert_eq!(transport.peer_mtu("alice"), BLE_MAX_FRAGMENT_SIZE);
1706        assert!(
1707            !transport.peer_mtus.lock().unwrap().contains_key("alice"),
1708            "undersized renegotiation must drop the stale entry"
1709        );
1710    }
1711
1712    #[test]
1713    fn test_ble_stop_drains_peer_mtus() {
1714        // stop() must clear per-peer MTUs so a subsequent start() cannot
1715        // observe stale values from the prior session.
1716        let transport = BleTransport::new("test-device");
1717        transport.set_peer_mtu("alice", 400);
1718        transport.set_peer_mtu("bob", 300);
1719        assert_eq!(transport.peer_mtu("alice"), 400);
1720        assert_eq!(transport.peer_mtu("bob"), 300);
1721
1722        transport.stop().unwrap();
1723
1724        assert!(transport.peer_mtus.lock().unwrap().is_empty());
1725        assert_eq!(transport.peer_mtu("alice"), BLE_MAX_FRAGMENT_SIZE);
1726        assert_eq!(transport.peer_mtu("bob"), BLE_MAX_FRAGMENT_SIZE);
1727    }
1728
1729    #[test]
1730    fn test_ble_stop_drains_all_per_session_state() {
1731        // stop() must drain every per-session cache in lockstep. A
1732        // one-sided drain would let `send()` pass its peer guard while
1733        // the fragmenter silently falls back to the 185-byte floor, or
1734        // would let stale reassembly state outlive the peer it came
1735        // from.
1736        let transport = BleTransport::new("test-device");
1737        transport.start().unwrap();
1738        transport.on_peer_discovered(peer_device("bob"));
1739        transport.set_peer_mtu("bob", 400);
1740        transport.send(&small_message()).unwrap();
1741        // Seed a partial reassembly buffer by feeding one of two
1742        // fragments from a larger message.
1743        let big = Message::builder(
1744            UserId::new("alice").unwrap(),
1745            UserId::new("bob").unwrap(),
1746            AppId::new("app").unwrap(),
1747        )
1748        .content("z".repeat(600))
1749        .build();
1750        let frags = transport.fragment_message(&big).unwrap();
1751        assert!(frags.len() > 1);
1752        transport.on_fragment_received(frags[0].clone()).unwrap();
1753        // Queue a received message so the receive_queue is non-empty.
1754        transport.on_message_received(small_message());
1755
1756        assert!(!transport.peers.lock().unwrap().is_empty());
1757        assert!(!transport.peer_mtus.lock().unwrap().is_empty());
1758        assert!(!transport.fragment_buffers.lock().unwrap().is_empty());
1759        assert!(
1760            !transport.send_queue.lock().unwrap().is_empty()
1761                || !transport.pending_fragments.lock().unwrap().is_empty()
1762        );
1763        assert!(!transport.receive_queue.lock().unwrap().is_empty());
1764
1765        transport.stop().unwrap();
1766
1767        assert!(transport.peers.lock().unwrap().is_empty());
1768        assert!(transport.peer_mtus.lock().unwrap().is_empty());
1769        assert!(transport.fragment_buffers.lock().unwrap().is_empty());
1770        assert!(transport.send_queue.lock().unwrap().is_empty());
1771        assert!(transport.pending_fragments.lock().unwrap().is_empty());
1772        assert!(transport.receive_queue.lock().unwrap().is_empty());
1773    }
1774
1775    #[test]
1776    fn test_ble_on_peer_lost_clears_stored_mtu() {
1777        let transport = BleTransport::new("test-device");
1778        transport.on_peer_discovered(peer_device("alice"));
1779        transport.set_peer_mtu("alice", 400);
1780        assert_eq!(transport.peer_mtu("alice"), 400);
1781
1782        transport.on_peer_lost("alice");
1783        // Peer map and MTU map must drop in lockstep so a reconnect cannot
1784        // observe a stale value from the previous link.
1785        assert!(transport.get_peer("alice").is_none());
1786        assert_eq!(transport.peer_mtu("alice"), BLE_MAX_FRAGMENT_SIZE);
1787    }
1788
1789    #[test]
1790    fn test_ble_clear_peer_mtu_reverts_to_fallback() {
1791        let transport = BleTransport::new("test-device");
1792        transport.set_peer_mtu("alice", 400);
1793        assert_eq!(transport.peer_mtu("alice"), 400);
1794        transport.clear_peer_mtu("alice");
1795        assert_eq!(transport.peer_mtu("alice"), BLE_MAX_FRAGMENT_SIZE);
1796    }
1797
1798    #[test]
1799    fn test_ble_fragment_size_differs_per_peer() {
1800        let transport = BleTransport::new("test-device");
1801        let sender = UserId::new("alice").unwrap();
1802        let app_id = AppId::new("app").unwrap();
1803        let content = "y".repeat(1024);
1804
1805        // Two messages with identical content but different recipients so
1806        // the fragmenter keys into different per-peer MTU slots.
1807        let msg_small = Message::builder(
1808            sender.clone(),
1809            UserId::new("small-peer").unwrap(),
1810            app_id.clone(),
1811        )
1812        .content(content.clone())
1813        .build();
1814        let msg_big = Message::builder(sender, UserId::new("big-peer").unwrap(), app_id)
1815            .content(content)
1816            .build();
1817
1818        // `fragment_message` warns if the recipient is not a registered
1819        // direct peer — reflect the real send() precondition in the test
1820        // setup so the warn path does not trigger spurious log noise.
1821        transport.on_peer_discovered(peer_device("small-peer"));
1822        transport.on_peer_discovered(peer_device("big-peer"));
1823        transport.set_peer_mtu("small-peer", BLE_MAX_FRAGMENT_SIZE);
1824        transport.set_peer_mtu("big-peer", 500);
1825
1826        let small_frags = transport.fragment_message(&msg_small).unwrap();
1827        let big_frags = transport.fragment_message(&msg_big).unwrap();
1828
1829        // Larger MTU ⇒ fewer fragments, each individual fragment larger.
1830        assert!(big_frags.len() < small_frags.len());
1831        let max_small = small_frags.iter().map(|f| f.len()).max().unwrap();
1832        let max_big = big_frags.iter().map(|f| f.len()).max().unwrap();
1833        assert!(max_big > max_small);
1834        assert!(max_small <= BLE_MAX_FRAGMENT_SIZE);
1835        assert!(max_big <= 500);
1836    }
1837
1838    #[test]
1839    fn test_ble_fragment_unknown_peer_uses_fallback() {
1840        let transport = BleTransport::new("test-device");
1841        // "bob" is a registered direct peer, but no MTU has been reported
1842        // for it yet — the fragmenter must fall back to
1843        // BLE_MAX_FRAGMENT_SIZE.
1844        transport.on_peer_discovered(peer_device("bob"));
1845        let msg = small_message();
1846        let fragments = transport.fragment_message(&msg).unwrap();
1847        for fragment in &fragments {
1848            assert!(fragment.len() <= BLE_MAX_FRAGMENT_SIZE);
1849        }
1850    }
1851
1852    #[test]
1853    fn test_ble_upward_renegotiation_overwrites_stored_mtu() {
1854        // Mid-link upward renegotiation: a peer starts at 400, the
1855        // controller later renegotiates up to 500. The stored entry must
1856        // track the latest value so subsequent fragments size against the
1857        // real capacity rather than remaining pinned at the first
1858        // negotiated value.
1859        let transport = BleTransport::new("test-device");
1860        transport.on_peer_discovered(peer_device("alice"));
1861
1862        transport.set_peer_mtu("alice", 400);
1863        assert_eq!(transport.peer_mtu("alice"), 400);
1864
1865        transport.set_peer_mtu("alice", 500);
1866        assert_eq!(transport.peer_mtu("alice"), 500);
1867
1868        // And the fragmenter actually picks up the new size — emit the
1869        // same payload before and after and observe that the post-
1870        // renegotiation batch has larger individual fragments.
1871        let msg = Message::builder(
1872            UserId::new("sender").unwrap(),
1873            UserId::new("alice").unwrap(),
1874            AppId::new("app").unwrap(),
1875        )
1876        .content("x".repeat(1024))
1877        .build();
1878
1879        transport.set_peer_mtu("alice", 300);
1880        let small_frags = transport.fragment_message(&msg).unwrap();
1881        transport.set_peer_mtu("alice", 500);
1882        let big_frags = transport.fragment_message(&msg).unwrap();
1883
1884        let max_small = small_frags.iter().map(|f| f.len()).max().unwrap();
1885        let max_big = big_frags.iter().map(|f| f.len()).max().unwrap();
1886        assert!(
1887            max_big > max_small,
1888            "upward reneg must produce larger fragments: {} vs {}",
1889            max_big,
1890            max_small
1891        );
1892        assert!(big_frags.len() < small_frags.len());
1893    }
1894
1895    #[test]
1896    fn test_ble_stop_start_round_trip_is_fresh() {
1897        // stop() must leave the transport in a state where a subsequent
1898        // start() observes no carry-over from the prior session: no
1899        // stored MTUs, no peers, no queued fragments. Round-trip through
1900        // a full start → seed → stop → start → verify cycle.
1901        // `small_message()` has recipient "bob", so the seeded peer must
1902        // also be "bob" for the send() precondition to pass. Keying the
1903        // test to match the real send-path invariant (recipient ==
1904        // device_id) catches any regression where stop/start leaks a
1905        // ghost peer under a different key.
1906        let transport = BleTransport::new("test-device");
1907
1908        transport.start().unwrap();
1909        transport.on_peer_discovered(peer_device("bob"));
1910        transport.set_peer_mtu("bob", 400);
1911        transport.send(&small_message()).unwrap();
1912        assert_eq!(transport.peer_mtu("bob"), 400);
1913
1914        transport.stop().unwrap();
1915        transport.start().unwrap();
1916
1917        // Peer map, MTU map, and send queue must all be empty — a fresh
1918        // start observes no residue from the prior session.
1919        assert!(transport.get_peer("bob").is_none());
1920        assert_eq!(transport.peer_mtu("bob"), BLE_MAX_FRAGMENT_SIZE);
1921
1922        // After re-start, `send()` must fail because there are no peers
1923        // — which is exactly the "at-rest" precondition start() must
1924        // satisfy. If any per-session state had leaked, `send()` would
1925        // succeed against a ghost peer.
1926        let result = transport.send(&small_message());
1927        assert!(
1928            matches!(result, Err(crate::Error::PeerNotReachable(_))),
1929            "send after stop/start must report no reachable peer, got {:?}",
1930            result
1931        );
1932
1933        // Re-seeding the same peer with a different MTU works as
1934        // expected — no stale entry shadowing the new value.
1935        transport.on_peer_discovered(peer_device("bob"));
1936        transport.set_peer_mtu("bob", 250);
1937        assert_eq!(transport.peer_mtu("bob"), 250);
1938    }
1939
1940    #[test]
1941    fn test_ble_undersized_mtu_reports_counter() {
1942        // The counter must:
1943        //   - start at zero
1944        //   - increment exactly once per undersized report
1945        //   - NOT increment on accepted values (including exact floor)
1946        //   - NOT increment on clamped-down oversized values
1947        //   - survive the clear_peer_mtu path (monotonic lifetime counter,
1948        //     never reset)
1949        let transport = BleTransport::new("test-device");
1950        assert_eq!(transport.undersized_mtu_reports(), 0);
1951
1952        transport.set_peer_mtu("alice", 20);
1953        assert_eq!(transport.undersized_mtu_reports(), 1);
1954
1955        // Accepted value — no increment.
1956        transport.set_peer_mtu("bob", 400);
1957        assert_eq!(transport.undersized_mtu_reports(), 1);
1958
1959        // Exact floor — accepted, no increment.
1960        transport.set_peer_mtu("carol", BLE_MAX_FRAGMENT_SIZE);
1961        assert_eq!(transport.undersized_mtu_reports(), 1);
1962
1963        // Oversized — clamped down, no increment.
1964        transport.set_peer_mtu("dave", 9999);
1965        assert_eq!(transport.undersized_mtu_reports(), 1);
1966
1967        // Another undersized report — increment to 2.
1968        transport.set_peer_mtu("eve", 10);
1969        assert_eq!(transport.undersized_mtu_reports(), 2);
1970
1971        // Monotonic across clear_peer_mtu — this is a lifetime counter,
1972        // not a per-peer state.
1973        transport.clear_peer_mtu("bob");
1974        assert_eq!(transport.undersized_mtu_reports(), 2);
1975
1976        // Undersized renegotiation of an existing peer still counts.
1977        transport.set_peer_mtu("alice", 15);
1978        assert_eq!(transport.undersized_mtu_reports(), 3);
1979    }
1980
1981    #[test]
1982    fn test_ble_fragment_fallback_count_increments_on_registered_peer_without_mtu() {
1983        // The keying-contract telemetry counter must:
1984        //   - start at zero
1985        //   - increment exactly once per fragment_message call that
1986        //     falls back to BLE_MAX_FRAGMENT_SIZE because the recipient
1987        //     is a registered direct peer with no stored per-peer MTU
1988        //     (the contract-break case)
1989        //   - NOT increment once an MTU has been recorded for that peer
1990        //   - NOT increment when the recipient is not a registered
1991        //     direct peer (the benign send/on_peer_lost race — see
1992        //     `test_ble_fragment_fallback_count_ignores_teardown_race`)
1993        let transport = BleTransport::new("test-device");
1994        transport.on_peer_discovered(peer_device("bob"));
1995        assert_eq!(transport.fragment_fallback_count(), 0);
1996
1997        // No MTU stored for "bob" but it IS a registered direct peer —
1998        // this is the contract-break shape and must increment.
1999        let msg = small_message();
2000        transport.fragment_message(&msg).unwrap();
2001        assert_eq!(transport.fragment_fallback_count(), 1);
2002
2003        // Another send in the same pre-flush window — increment again.
2004        transport.fragment_message(&msg).unwrap();
2005        assert_eq!(transport.fragment_fallback_count(), 2);
2006
2007        // Record an MTU; subsequent sends must NOT increment.
2008        transport.set_peer_mtu("bob", 400);
2009        transport.fragment_message(&msg).unwrap();
2010        transport.fragment_message(&msg).unwrap();
2011        assert_eq!(transport.fragment_fallback_count(), 2);
2012
2013        // Clearing the MTU re-opens the fallback window for a still-
2014        // registered peer — increment again.
2015        transport.clear_peer_mtu("bob");
2016        transport.fragment_message(&msg).unwrap();
2017        assert_eq!(transport.fragment_fallback_count(), 3);
2018    }
2019
2020    #[test]
2021    fn test_ble_fragment_fallback_count_ignores_teardown_race() {
2022        // Benign send/on_peer_lost race: `send()` validates and
2023        // enqueues while the peer is live, then `on_peer_lost` drops
2024        // both `peers` and `peer_mtus` in lockstep, then
2025        // `get_next_fragment` pops the queued message and calls
2026        // `fragment_message`. The miss branch must NOT increment
2027        // `fragment_fallback_count` in this shape, because the
2028        // invariant (MTU-before-discover) was not violated — the
2029        // peer simply evaporated mid-send. Counting this case would
2030        // drown the real signal out on every disconnect with
2031        // in-flight sends.
2032        let transport = BleTransport::new("test-device");
2033        transport.on_peer_discovered(peer_device("bob"));
2034        transport.set_peer_mtu("bob", 400);
2035        assert_eq!(transport.fragment_fallback_count(), 0);
2036
2037        // Simulate the peer disappearing between `send()` enqueue and
2038        // `get_next_fragment` fragmentation. `fragment_message` is
2039        // called directly (mirroring the code path inside
2040        // `get_next_fragment`) for a recipient that is neither in
2041        // `peers` nor in `peer_mtus`.
2042        transport.on_peer_lost("bob");
2043        let msg = small_message();
2044        transport.fragment_message(&msg).unwrap();
2045
2046        assert_eq!(
2047            transport.fragment_fallback_count(),
2048            0,
2049            "teardown race must not tick the contract-break counter"
2050        );
2051    }
2052
2053    #[test]
2054    fn test_ble_golden_path_handshake_never_falls_back() {
2055        // Pins the MTU-before-discover ordering invariant from the
2056        // Rust side. Both platform layers MUST call
2057        // `set_peer_mtu` before `on_peer_discovered` so that by the
2058        // time any fragmenting send can reach a live peer the MTU
2059        // is already on file — see `BleManager.swift`
2060        // (`didUpdateValueFor` DEVICE_ID branch: `bleSetPeerMtu`
2061        // precedes `blePeerDiscovered`) and
2062        // `BleTransportFacade.kt` (`handleDeviceIdRead` flushes
2063        // staged MTU via `onDeviceIdResolved` before
2064        // `blePeerDiscovered`). A regression that swaps the order
2065        // on either platform would let the first send fall back,
2066        // and with `peers.contains_key` now gating the counter,
2067        // that exact shape (registered direct peer + no MTU on
2068        // file) is what this test pins.
2069        //
2070        // If this test starts failing, either a platform layer
2071        // reordered its handshake OR a refactor introduced a
2072        // recipient -> device_id translation that breaks the
2073        // keying contract. Do NOT "fix" by registering an MTU
2074        // inside this test — go find the regression.
2075        let transport = BleTransport::new("test-device");
2076
2077        // Golden ordering: MTU first, then peer-discovered — same
2078        // as the platforms.
2079        transport.set_peer_mtu("bob", 400);
2080        transport.on_peer_discovered(peer_device("bob"));
2081
2082        let msg = small_message();
2083        let fragments = transport.fragment_message(&msg).unwrap();
2084        assert!(!fragments.is_empty());
2085
2086        assert_eq!(
2087            transport.fragment_fallback_count(),
2088            0,
2089            "golden-path handshake must not fall back — the \
2090             platform-side MTU-before-discover invariant regressed, \
2091             or the recipient -> device_id keying contract broke"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_ble_peripheral_role_disconnect_must_not_clear_central_mtu() {
2097        // Regression pin for the bug fixed in commit c0ff33e (see
2098        // BleTransportFacade.handleCentralDisconnectedOnMain).
2099        //
2100        // The Rust transport has no concept of central vs. peripheral
2101        // role — there is one peer, one MTU. The platform layers own
2102        // which role "populated" a given entry. Before the fix, the
2103        // Android facade's peripheral-role disconnect handler was
2104        // calling `bleClearPeerMtu` under the mistaken belief that the
2105        // peripheral-role disconnect owned MTU teardown. It did not:
2106        // `peer_mtus[deviceId]` is populated by the central-role link
2107        // via `CentralGattClient.onMtuChanged → flushPeerMtu`, and
2108        // clearing it on a peripheral-role disconnect demoted an alive
2109        // central-role link from its negotiated BLE 5 MTU back to the
2110        // 185-byte floor for the rest of the link's life.
2111        //
2112        // This test pins the Rust-side contract that the platform fix
2113        // depends on: the *only* valid signals that affect
2114        // `peer_mtus[X]` are `set_peer_mtu(X, ...)`,
2115        // `clear_peer_mtu(X)`, `on_peer_lost(X)`, and the full-state
2116        // drains on `stop()`. A peripheral-role disconnect that
2117        // (correctly) calls none of those must leave fragment sizing
2118        // untouched. If a future refactor causes `fragment_message`
2119        // to re-read state in a way that is not idempotent across
2120        // "no-op" calls, this test catches it.
2121        let transport = BleTransport::new("test-device");
2122        transport.on_peer_discovered(peer_device("bob"));
2123        transport.set_peer_mtu("bob", 400);
2124
2125        let msg = Message::builder(
2126            UserId::new("alice").unwrap(),
2127            UserId::new("bob").unwrap(),
2128            AppId::new("app").unwrap(),
2129        )
2130        .content("x".repeat(1024))
2131        .build();
2132
2133        let pre_frags = transport.fragment_message(&msg).unwrap();
2134        let pre_max = pre_frags.iter().map(|f| f.len()).max().unwrap();
2135        assert!(
2136            pre_max > BLE_MAX_FRAGMENT_SIZE,
2137            "pre-disconnect fragments must size against the negotiated 400-byte MTU"
2138        );
2139
2140        // Simulated peripheral-role disconnect: the fix requires the
2141        // platform layer to call *none* of `on_peer_lost`,
2142        // `clear_peer_mtu`, `set_peer_mtu`. Anything else here would
2143        // either clobber the central-role MTU state or declare the
2144        // peer universally lost.
2145
2146        let post_frags = transport.fragment_message(&msg).unwrap();
2147        let post_max = post_frags.iter().map(|f| f.len()).max().unwrap();
2148        assert_eq!(
2149            pre_max, post_max,
2150            "peripheral-role disconnect must not change fragment sizing for an alive central-role link"
2151        );
2152        assert_eq!(pre_frags.len(), post_frags.len());
2153        assert_eq!(
2154            transport.fragment_fallback_count(),
2155            0,
2156            "a no-op peripheral-role disconnect must not tick the keying-contract counter"
2157        );
2158        assert_eq!(
2159            transport.peer_mtu("bob"),
2160            400,
2161            "peer MTU must survive a peripheral-role disconnect at the Rust layer"
2162        );
2163    }
2164
2165    #[test]
2166    fn test_ble_platform_handle() {
2167        let transport = BleTransport::new("test-device");
2168        assert_eq!(transport.platform_handle(), None);
2169        transport.set_platform_handle(42);
2170        assert_eq!(transport.platform_handle(), Some(42));
2171    }
2172
2173    #[test]
2174    fn test_ble_update_metrics() {
2175        let transport = BleTransport::new("test-device");
2176        let mut m = TransportMetrics::default();
2177        m.rssi = Some(-70);
2178        transport.update_metrics(m);
2179        assert_eq!(transport.metrics().rssi, Some(-70));
2180    }
2181
2182    #[test]
2183    fn test_ble_peer_discovery() {
2184        let transport = BleTransport::new("test-device");
2185        transport.on_peer_discovered(peer_device("peer-1"));
2186        let peers = transport.get_peers();
2187        assert_eq!(peers.len(), 1);
2188        assert_eq!(peers[0].device_id, "peer-1");
2189        assert!(transport.get_peer("peer-1").is_some());
2190        assert!(transport.get_peer("other").is_none());
2191    }
2192
2193    #[test]
2194    fn test_ble_peer_lost() {
2195        let transport = BleTransport::new("test-device");
2196        transport.on_peer_discovered(peer_device("peer-1"));
2197        transport.on_peer_lost("peer-1");
2198        assert_eq!(transport.get_peers().len(), 0);
2199    }
2200
2201    #[test]
2202    fn test_ble_has_pending_sends_dequeue_send_get_queue_depth() {
2203        let transport = BleTransport::new("test-device");
2204        transport.start().unwrap();
2205        transport.on_peer_discovered(peer_device("bob"));
2206        let msg = small_message();
2207        transport.send(&msg).unwrap();
2208        assert!(transport.has_pending_sends());
2209        assert_eq!(transport.get_queue_depth(), 1);
2210        let dequeued = transport.dequeue_send();
2211        assert!(dequeued.is_some());
2212        assert!(!transport.has_pending_sends());
2213        assert_eq!(transport.get_queue_depth(), 0);
2214        assert!(transport.dequeue_send().is_none());
2215    }
2216
2217    #[test]
2218    fn test_ble_serialize_deserialize_message() {
2219        let transport = BleTransport::new("test-device");
2220        let msg = small_message();
2221        let data = transport.serialize_message(&msg).unwrap();
2222        let back = transport.deserialize_message(&data).unwrap();
2223        assert_eq!(back.id, msg.id);
2224        assert_eq!(back.content, msg.content);
2225    }
2226
2227    #[test]
2228    fn test_ble_deserialize_invalid_json() {
2229        let transport = BleTransport::new("test-device");
2230        let result = transport.deserialize_message(b"not json");
2231        assert!(result.is_err());
2232        assert!(matches!(
2233            result.unwrap_err(),
2234            crate::Error::SerializationError(_)
2235        ));
2236    }
2237
2238    #[test]
2239    fn test_ble_single_fragment_roundtrip() {
2240        let transport = BleTransport::new("test-device");
2241        transport.on_peer_discovered(peer_device("bob"));
2242        transport.set_peer_mtu("bob", 512);
2243        let msg = small_message();
2244        let fragments = transport.fragment_message(&msg).unwrap();
2245        assert_eq!(
2246            fragments.len(),
2247            1,
2248            "small message with large MTU should fit in one fragment"
2249        );
2250        let reconstructed = transport.process_fragment(&fragments[0]).unwrap();
2251        assert!(reconstructed.is_some());
2252        assert_eq!(reconstructed.unwrap().content, msg.content);
2253    }
2254
2255    #[test]
2256    fn test_ble_fragment_roundtrip() {
2257        let transport = BleTransport::new("test-device");
2258        transport.on_peer_discovered(peer_device("bob"));
2259        let sender = UserId::new("alice").unwrap();
2260        let recipient = UserId::new("bob").unwrap();
2261        let app_id = AppId::new("app").unwrap();
2262        let content = "x".repeat(512);
2263
2264        let message = Message::builder(sender, recipient, app_id)
2265            .content(content.clone())
2266            .priority(MessagePriority::High)
2267            .ttl(TTL::new(8).unwrap())
2268            .build();
2269
2270        let fragments = transport.fragment_message(&message).unwrap();
2271        assert!(fragments.len() > 1);
2272        for fragment in &fragments {
2273            assert!(fragment.len() <= BLE_MAX_FRAGMENT_SIZE);
2274        }
2275
2276        let mut reconstructed = None;
2277        for fragment in fragments {
2278            if let Some(msg) = transport.process_fragment(&fragment).unwrap() {
2279                reconstructed = Some(msg);
2280            }
2281        }
2282
2283        let reconstructed = reconstructed.expect("Expected complete message");
2284        assert_eq!(reconstructed.content, content);
2285    }
2286
2287    #[test]
2288    fn test_ble_fragment_roundtrip_binary_wire() {
2289        // A binary-stamped message must survive fragmentation and reassembly:
2290        // the wire-v1 magic byte rides in the first fragment's payload and the
2291        // reassembly path auto-detects it, so the frame decodes back intact.
2292        let transport = BleTransport::new("test-device");
2293        transport.on_peer_discovered(peer_device("bob"));
2294        let sender = UserId::new("alice").unwrap();
2295        let recipient = UserId::new("bob").unwrap();
2296        let app_id = AppId::new("app").unwrap();
2297        let content = "x".repeat(512);
2298
2299        let mut message = Message::builder(sender, recipient, app_id)
2300            .content(content.clone())
2301            .priority(MessagePriority::High)
2302            .ttl(TTL::new(8).unwrap())
2303            .build();
2304        // Stamp binary, exactly as the transport manager does for a peer that
2305        // advertised support; `fragment_message` serializes via
2306        // `serialize_message_with`, which honors the stamp.
2307        message.set_wire_codec(offline_protocol_core::WireCodec::BinaryV1);
2308        assert_eq!(
2309            transport.serialize_message(&message).unwrap().first(),
2310            Some(&offline_protocol_core::WIRE_V1_MAGIC),
2311            "a binary-stamped message serializes to a wire-v1 frame"
2312        );
2313
2314        let fragments = transport.fragment_message(&message).unwrap();
2315        assert!(
2316            fragments.len() > 1,
2317            "512-byte content must span multiple BLE fragments"
2318        );
2319
2320        let mut reconstructed = None;
2321        for fragment in fragments {
2322            if let Some(msg) = transport.process_fragment(&fragment).unwrap() {
2323                reconstructed = Some(msg);
2324            }
2325        }
2326        let reconstructed = reconstructed.expect("Expected complete message");
2327        assert_eq!(reconstructed.content, content);
2328        assert_eq!(reconstructed.id, message.id);
2329        // Reassembly decodes back to a plain message; the codec stamp is not
2330        // carried on the wire (it defaults to JSON), matching the JSON path.
2331        assert_eq!(
2332            reconstructed.wire_codec(),
2333            offline_protocol_core::WireCodec::Json
2334        );
2335    }
2336
2337    #[test]
2338    fn test_ble_process_fragment_invalid_magic() {
2339        let transport = BleTransport::new("test-device");
2340        let mut bad = vec![0x00, 0x00, 1, 0, 0, 0, 0, 0, 0, 0]; // wrong magic
2341        bad.extend_from_slice(b"{}");
2342        let result = transport.process_fragment(&bad);
2343        assert!(result.is_err());
2344        assert!(matches!(result.unwrap_err(), crate::Error::Other(s) if s.contains("magic")));
2345    }
2346
2347    #[test]
2348    fn test_ble_process_fragment_too_short() {
2349        let transport = BleTransport::new("test-device");
2350        let result = transport.process_fragment(&[0x4f, 0x50]); // "OP" only
2351        assert!(result.is_err());
2352        assert!(
2353            matches!(result.unwrap_err(), crate::Error::Other(s) if s.contains("short") || s.contains("truncat"))
2354        );
2355    }
2356
2357    #[test]
2358    fn test_ble_process_fragment_wrong_version() {
2359        let transport = BleTransport::new("test-device");
2360        // Minimal header with wrong version: magic(2) + version(1)=99 + id_len(1)=0 + index(2) + total(2) + data_len(2)
2361        let bad = [b'O', b'P', 99u8, 0u8, 0u8, 0u8, 1u8, 0u8, 0u8, 0u8];
2362        let result = transport.process_fragment(&bad);
2363        assert!(result.is_err());
2364        assert!(matches!(result.unwrap_err(), crate::Error::Other(s) if s.contains("version")));
2365    }
2366
2367    #[test]
2368    fn test_ble_on_fragment_received_complete_queues_message() {
2369        let transport = BleTransport::new("test-device");
2370        transport.on_peer_discovered(peer_device("bob"));
2371        transport.set_peer_mtu("bob", 512);
2372        let msg = small_message();
2373        let fragments = transport.fragment_message(&msg).unwrap();
2374        for fragment in &fragments {
2375            transport.on_fragment_received(fragment.clone()).unwrap();
2376        }
2377        let received = transport.receive().unwrap();
2378        assert!(received.is_some());
2379        assert_eq!(received.unwrap().content, msg.content);
2380    }
2381
2382    #[test]
2383    fn test_ble_on_fragment_received_bad_data_drops_ok() {
2384        let transport = BleTransport::new("test-device");
2385        let result = transport.on_fragment_received(vec![0u8; 5]);
2386        assert!(result.is_ok()); // drops bad fragment, doesn't propagate error
2387    }
2388
2389    #[test]
2390    fn test_ble_get_next_fragment_requeue() {
2391        let transport = BleTransport::new("test-device");
2392        transport.start().unwrap();
2393        transport.on_peer_discovered(peer_device("bob"));
2394        let msg = small_message();
2395        transport.send(&msg).unwrap();
2396        let first = transport.get_next_fragment().unwrap();
2397        assert!(first.is_some());
2398        let (recipient, data) = first.unwrap();
2399        transport.requeue_fragment(&recipient, data.clone());
2400        let again = transport.get_next_fragment().unwrap();
2401        assert!(again.is_some());
2402        assert_eq!(again.unwrap().1, data);
2403    }
2404
2405    #[test]
2406    fn test_ble_get_next_fragment_none_when_empty() {
2407        let transport = BleTransport::new("test-device");
2408        assert!(transport.get_next_fragment().unwrap().is_none());
2409    }
2410
2411    #[test]
2412    fn test_ble_on_message_received() {
2413        let transport = BleTransport::new("test-device");
2414        let msg = small_message();
2415        transport.on_message_received(msg.clone());
2416        let received = transport.receive().unwrap();
2417        assert!(received.is_some());
2418        assert_eq!(received.unwrap().id, msg.id);
2419    }
2420
2421    #[test]
2422    fn test_ble_send_rejects_unknown_peer() {
2423        let transport = BleTransport::new("test-device");
2424        transport.start().unwrap();
2425        let msg = small_message();
2426        let result = transport.send(&msg);
2427        assert!(result.is_err());
2428        assert!(matches!(
2429            result.unwrap_err(),
2430            crate::Error::PeerNotReachable(_)
2431        ));
2432    }
2433
2434    #[test]
2435    fn test_ble_send_allows_known_peer() {
2436        let transport = BleTransport::new("test-device");
2437        transport.start().unwrap();
2438        transport.on_peer_discovered(peer_device("bob"));
2439        let msg = small_message();
2440        assert!(transport.send(&msg).is_ok());
2441    }
2442
2443    #[test]
2444    fn test_ble_send_after_peer_lost() {
2445        let transport = BleTransport::new("test-device");
2446        transport.start().unwrap();
2447        transport.on_peer_discovered(peer_device("bob"));
2448        assert!(transport.send(&small_message()).is_ok());
2449        transport.on_peer_lost("bob");
2450        let result = transport.send(&small_message());
2451        assert!(matches!(
2452            result.unwrap_err(),
2453            crate::Error::PeerNotReachable(_)
2454        ));
2455    }
2456
2457    #[test]
2458    fn test_ble_transport_builder() {
2459        let transport = BleTransportBuilder::new("my-device").build();
2460        assert_eq!(transport.device_id(), "my-device");
2461        assert_eq!(transport.transport_type(), TransportType::BLE);
2462    }
2463
2464    #[test]
2465    fn test_ble_reassembled_payload_rejects_oversized() {
2466        let transport = BleTransport::new("test-device");
2467        // Split an oversized payload across many fragments so each individual
2468        // fragment's data_len fits in a u16 but the running total crosses
2469        // DEFAULT_MAX_MESSAGE_SIZE before the assembly completes. The bound is
2470        // enforced incrementally, so rejection fires on the first fragment that
2471        // pushes the buffered total over the limit — not only at completion.
2472        // chunk_size stays well under u16::MAX; `+ 2` guarantees the
2473        // reassembled total would exceed DEFAULT_MAX_MESSAGE_SIZE.
2474        let chunk_size: usize = 60_000;
2475        let num_fragments = (DEFAULT_MAX_MESSAGE_SIZE / chunk_size) + 2;
2476        // First fragment index whose cumulative bytes exceed the limit:
2477        // chunk_size * (i + 1) > DEFAULT_MAX_MESSAGE_SIZE  <=>  i >= floor(limit / chunk_size).
2478        let overflow_at = DEFAULT_MAX_MESSAGE_SIZE / chunk_size;
2479
2480        for i in 0..num_fragments {
2481            let frag = encode_fragment(
2482                b"big",
2483                i as u16,
2484                num_fragments as u16,
2485                &vec![0xAA; chunk_size],
2486            )
2487            .unwrap();
2488
2489            let result = transport.process_fragment(&frag);
2490            if i < overflow_at {
2491                // Under the limit so far — incomplete, buffered.
2492                assert!(result.unwrap().is_none());
2493            } else {
2494                // This fragment pushes the running total over the limit and
2495                // must be rejected before the assembly ever completes.
2496                assert!(matches!(
2497                    result.unwrap_err(),
2498                    crate::Error::MessageTooLarge(_, _)
2499                ));
2500                break;
2501            }
2502        }
2503
2504        // The overflowing assembly must have been dropped, not left parked.
2505        assert!(transport.fragment_buffers.lock().unwrap().is_empty());
2506    }
2507
2508    #[test]
2509    fn test_ble_process_fragment_rejects_excessive_total() {
2510        let transport = BleTransport::new("test-device");
2511
2512        // A fragment declaring more than BLE_MAX_FRAGMENT_COUNT fragments is
2513        // rejected up front, before any reassembly buffer is allocated.
2514        let over = encode_fragment(b"evil", 0, (BLE_MAX_FRAGMENT_COUNT + 1) as u16, b"x").unwrap();
2515        assert!(matches!(
2516            transport.process_fragment(&over).unwrap_err(),
2517            crate::Error::Other(s) if s.contains("Fragment count exceeds maximum")
2518        ));
2519
2520        // The u16 ceiling is likewise rejected.
2521        let max = encode_fragment(b"evil", 0, u16::MAX, b"x").unwrap();
2522        assert!(transport.process_fragment(&max).is_err());
2523
2524        // Neither rejected fragment left an assembly behind.
2525        assert!(transport.fragment_buffers.lock().unwrap().is_empty());
2526
2527        // The boundary value (exactly BLE_MAX_FRAGMENT_COUNT) is accepted and
2528        // buffered as an incomplete assembly.
2529        let ok = encode_fragment(b"good", 0, BLE_MAX_FRAGMENT_COUNT as u16, b"x").unwrap();
2530        assert!(transport.process_fragment(&ok).unwrap().is_none());
2531        assert_eq!(transport.fragment_buffers.lock().unwrap().len(), 1);
2532    }
2533
2534    #[test]
2535    fn test_ble_incremental_size_bound_rejects_before_completion() {
2536        let transport = BleTransport::new("test-device");
2537
2538        // A small, valid fragment count (well under BLE_MAX_FRAGMENT_COUNT) but
2539        // large per-fragment payloads: the running total must trip the size
2540        // bound long before the final fragment could arrive.
2541        let total_fragments: u16 = 30;
2542        let chunk_size: usize = 60_000;
2543        let overflow_at = DEFAULT_MAX_MESSAGE_SIZE / chunk_size;
2544
2545        let mut rejected_at = None;
2546        for i in 0..total_fragments as usize {
2547            let frag = encode_fragment(
2548                b"partial",
2549                i as u16,
2550                total_fragments,
2551                &vec![0x5A; chunk_size],
2552            )
2553            .unwrap();
2554            match transport.process_fragment(&frag) {
2555                Ok(opt) => assert!(opt.is_none(), "fragment {i} should not complete"),
2556                Err(crate::Error::MessageTooLarge(_, _)) => {
2557                    rejected_at = Some(i);
2558                    break;
2559                }
2560                Err(e) => panic!("unexpected error at fragment {i}: {e}"),
2561            }
2562        }
2563
2564        let rejected_at = rejected_at.expect("size bound should have rejected before completion");
2565        assert_eq!(
2566            rejected_at, overflow_at,
2567            "rejection should fire on the first overflowing fragment"
2568        );
2569        assert!(
2570            rejected_at < total_fragments as usize - 1,
2571            "rejection must happen strictly before the assembly could complete"
2572        );
2573        // The overflowing assembly was dropped, not parked.
2574        assert!(transport.fragment_buffers.lock().unwrap().is_empty());
2575    }
2576
2577    #[test]
2578    fn test_ble_duplicate_fragment_index_does_not_inflate_size() {
2579        let transport = BleTransport::new("test-device");
2580
2581        // Re-sending the same fragment index must overwrite, not accumulate:
2582        // 100 copies of a ~60 KiB fragment at index 0 stay well under the
2583        // 1 MiB bound because each replaces the last.
2584        let chunk_size: usize = 60_000;
2585        for _ in 0..100 {
2586            let frag = encode_fragment(b"dup", 0, 4, &vec![0xC3; chunk_size]).unwrap();
2587            assert!(
2588                transport.process_fragment(&frag).unwrap().is_none(),
2589                "duplicate index should never trip the size bound"
2590            );
2591        }
2592
2593        // A normal small message still reassembles cleanly afterwards.
2594        transport.on_peer_discovered(peer_device("bob"));
2595        transport.set_peer_mtu("bob", 244);
2596        let msg = small_message();
2597        let fragments = transport.fragment_message(&msg).unwrap();
2598        let mut done = None;
2599        for f in fragments {
2600            if let Some(m) = transport.process_fragment(&f).unwrap() {
2601                done = Some(m);
2602            }
2603        }
2604        assert_eq!(
2605            done.expect("message should reassemble").content,
2606            msg.content
2607        );
2608    }
2609
2610    #[test]
2611    fn test_ble_process_fragment_rejects_out_of_range_index() {
2612        let transport = BleTransport::new("test-device");
2613
2614        // A fragment whose index falls at or beyond the declared count must be
2615        // rejected before it is buffered. Without this, a peer could declare a
2616        // small `total_fragments` (satisfying the count cap) yet address tens of
2617        // thousands of distinct out-of-range indices.
2618        let total: u16 = 4;
2619        for bad_index in [total, total + 1, 100, u16::MAX] {
2620            let frag = encode_fragment(b"oob", bad_index, total, b"x").unwrap();
2621            assert!(
2622                matches!(
2623                    transport.process_fragment(&frag).unwrap_err(),
2624                    crate::Error::Other(s) if s.contains("Fragment index out of range")
2625                ),
2626                "index {bad_index} (total {total}) should be rejected"
2627            );
2628        }
2629
2630        // Nothing was buffered by the rejected fragments.
2631        assert!(transport.fragment_buffers.lock().unwrap().is_empty());
2632
2633        // The last valid index (total - 1) is still accepted and buffered.
2634        let ok = encode_fragment(b"oob", total - 1, total, b"x").unwrap();
2635        assert!(transport.process_fragment(&ok).unwrap().is_none());
2636        assert_eq!(transport.fragment_buffers.lock().unwrap().len(), 1);
2637    }
2638
2639    #[test]
2640    fn test_ble_process_fragment_rejects_zero_total() {
2641        let transport = BleTransport::new("test-device");
2642
2643        // `total_fragments == 0` describes an assembly that can never complete —
2644        // `fragments.len()` is at least 1 once any fragment lands, so it never
2645        // equals 0. The index-range guard rejects it for free: no u16 index is
2646        // less than 0, so every fragment claiming total 0 is out of range.
2647        for index in [0u16, 1, u16::MAX] {
2648            let frag = encode_fragment(b"zero", index, 0, b"x").unwrap();
2649            assert!(
2650                matches!(
2651                    transport.process_fragment(&frag).unwrap_err(),
2652                    crate::Error::Other(s) if s.contains("Fragment index out of range")
2653                ),
2654                "total_fragments == 0 (index {index}) should be rejected"
2655            );
2656        }
2657        assert!(transport.fragment_buffers.lock().unwrap().is_empty());
2658    }
2659
2660    #[test]
2661    fn test_ble_distinct_out_of_range_indices_cannot_bloat_assembly_map() {
2662        let transport = BleTransport::new("test-device");
2663
2664        // The byte-size bound only counts payload bytes, so empty/tiny fragments
2665        // never trip it, and the count cap bounds only how many fragments a
2666        // message may *declare* — not how many distinct indices actually land.
2667        // Without the index-range guard, a peer declaring `total_fragments = 2`
2668        // could park thousands of distinct out-of-range indices, each a HashMap
2669        // entry whose overhead the byte counter never sees. Every one must be
2670        // rejected, leaving no assembly behind.
2671        let total: u16 = 2;
2672        for index in 2u16..5_000 {
2673            let frag = encode_fragment(b"flood", index, total, b"").unwrap();
2674            assert!(
2675                transport.process_fragment(&frag).is_err(),
2676                "out-of-range index {index} must be rejected, not buffered"
2677            );
2678        }
2679        assert!(
2680            transport.fragment_buffers.lock().unwrap().is_empty(),
2681            "out-of-range indices must never create an assembly"
2682        );
2683    }
2684
2685    #[test]
2686    fn test_fragment_eviction_fires_callback() {
2687        let transport = BleTransport::new("test-device");
2688
2689        let evictions: Arc<Mutex<Vec<FragmentEvictionInfo>>> = Arc::new(Mutex::new(Vec::new()));
2690        let evictions_clone = evictions.clone();
2691        transport.set_fragment_eviction_callback(Some(Arc::new(move |info| {
2692            evictions_clone.lock().unwrap().push(info);
2693        })));
2694
2695        // Fill the reassembly buffer to capacity with partial assemblies.
2696        // Each entry: message_id "msg-N", fragment 0 of 4 (so 25% complete).
2697        for i in 0..BLE_MAX_FRAGMENT_ASSEMBLIES {
2698            let msg_id = format!("msg-{i}");
2699            let frag = encode_fragment(msg_id.as_bytes(), 0, 4, b"payload-data").unwrap();
2700            let result = transport.process_fragment(&frag).unwrap();
2701            assert!(result.is_none(), "partial fragment should not complete");
2702        }
2703
2704        // No evictions yet
2705        assert!(evictions.lock().unwrap().is_empty());
2706
2707        // Add one more — must trigger eviction of the least-valuable entry.
2708        let trigger_frag = encode_fragment(b"new-msg", 0, 4, b"payload-data").unwrap();
2709        let result = transport.process_fragment(&trigger_frag).unwrap();
2710        assert!(result.is_none());
2711
2712        let captured = evictions.lock().unwrap();
2713        assert_eq!(captured.len(), 1, "exactly one eviction should fire");
2714        assert_eq!(captured[0].completion_percent, 25);
2715        assert!(
2716            captured[0].message_id.starts_with("msg-"),
2717            "evicted entry should be one of the pre-filled assemblies"
2718        );
2719    }
2720
2721    #[test]
2722    fn test_fragment_eviction_callback_can_reenter_transport() {
2723        let transport = Arc::new(BleTransport::new("test-device"));
2724
2725        let fired = Arc::new(Mutex::new(false));
2726        let fired_clone = Arc::clone(&fired);
2727        let transport_clone = Arc::clone(&transport);
2728        transport.set_fragment_eviction_callback(Some(Arc::new(move |_info| {
2729            // Re-enters the transport from inside the callback. If the
2730            // eviction fired with the callback mutex held, re-locking it
2731            // here would self-deadlock. Clearing the callback while it is
2732            // executing is safe because the caller holds its own Arc clone.
2733            transport_clone.set_fragment_eviction_callback(None);
2734            *fired_clone.lock().unwrap() = true;
2735        })));
2736
2737        // Fill the reassembly buffer to capacity, then push one more
2738        // assembly to trigger an eviction (same setup as
2739        // test_fragment_eviction_fires_callback).
2740        for i in 0..BLE_MAX_FRAGMENT_ASSEMBLIES {
2741            let msg_id = format!("msg-{i}");
2742            let frag = encode_fragment(msg_id.as_bytes(), 0, 4, b"payload-data").unwrap();
2743            assert!(transport.process_fragment(&frag).unwrap().is_none());
2744        }
2745        let trigger_frag = encode_fragment(b"new-msg", 0, 4, b"payload-data").unwrap();
2746        assert!(transport.process_fragment(&trigger_frag).unwrap().is_none());
2747
2748        assert!(
2749            *fired.lock().unwrap(),
2750            "eviction callback should have fired"
2751        );
2752    }
2753
2754    /// A capacity eviction drops an inbound partial message. That is not an
2755    /// outcome of any send of ours, so it must leave the delivery counters
2756    /// untouched; recording it as a send failure, with no success ever
2757    /// recorded, pinned BLE's delivery ratio at zero (#557).
2758    #[test]
2759    fn a_capacity_eviction_is_not_a_send_failure() {
2760        let transport = BleTransport::new("test-device");
2761        for i in 0..BLE_MAX_FRAGMENT_ASSEMBLIES {
2762            let msg_id = format!("msg-{i}");
2763            let frag = encode_fragment(msg_id.as_bytes(), 0, 4, b"payload-data").unwrap();
2764            assert!(transport.process_fragment(&frag).unwrap().is_none());
2765        }
2766        let trigger_frag = encode_fragment(b"new-msg", 0, 4, b"payload-data").unwrap();
2767        assert!(transport.process_fragment(&trigger_frag).unwrap().is_none());
2768        assert_eq!(
2769            transport.fragment_buffers.lock_or_recover().len(),
2770            BLE_MAX_FRAGMENT_ASSEMBLIES,
2771            "one assembly must have been evicted to admit the new one"
2772        );
2773
2774        let metrics = transport.metrics();
2775        assert_eq!(metrics.sample_count(), 0);
2776        assert_eq!(metrics.delivery_ratio, None);
2777        assert_eq!(metrics.drop_rate, None);
2778    }
2779
2780    /// The idle-timeout path drops an inbound partial message too, and is
2781    /// equally not a send outcome (#557).
2782    #[test]
2783    fn an_idle_expired_assembly_is_not_a_send_failure() {
2784        let transport = BleTransport::new("test-device");
2785        let stale = SystemTime::now() - StdDuration::from_secs(BLE_FRAGMENT_TIMEOUT_SECS + 1);
2786        transport.fragment_buffers.lock_or_recover().insert(
2787            "stale-msg".to_string(),
2788            FragmentAssembly {
2789                total_fragments: 4,
2790                fragments: HashMap::from([(0, b"payload-data".to_vec())]),
2791                started_at: stale,
2792                last_seen: stale,
2793                buffered_bytes: 12,
2794            },
2795        );
2796
2797        // Any received fragment runs the idle cleanup first.
2798        let frag = encode_fragment(b"fresh-msg", 0, 4, b"payload-data").unwrap();
2799        assert!(transport.process_fragment(&frag).unwrap().is_none());
2800
2801        let buffers = transport.fragment_buffers.lock_or_recover();
2802        assert!(!buffers.contains_key("stale-msg"), "stale assembly expired");
2803        assert!(buffers.contains_key("fresh-msg"));
2804        drop(buffers);
2805
2806        let metrics = transport.metrics();
2807        assert_eq!(metrics.sample_count(), 0);
2808        assert_eq!(metrics.delivery_ratio, None);
2809        assert_eq!(metrics.drop_rate, None);
2810    }
2811
2812    #[test]
2813    fn an_acknowledgement_is_queued_ahead_of_data_but_behind_earlier_acknowledgements() {
2814        let transport = BleTransport::new("test-device");
2815        transport.on_status_changed(TransportStatus::Available);
2816        transport.on_peer_discovered(peer_device("bob"));
2817
2818        let data: Vec<Message> = (0..3).map(|_| small_message()).collect();
2819        let mut ack = small_message();
2820        ack.metadata
2821            .insert(ACK_FOR_KEY.to_string(), "first".to_string());
2822        let mut second_ack = small_message();
2823        second_ack
2824            .metadata
2825            .insert(ACK_FOR_KEY.to_string(), "second".to_string());
2826
2827        transport.send(&data[0]).unwrap();
2828        transport.send(&data[1]).unwrap();
2829        transport.send(&ack).unwrap();
2830        transport.send(&data[2]).unwrap();
2831        transport.send(&second_ack).unwrap();
2832
2833        let order: Vec<_> = transport
2834            .send_queue
2835            .lock()
2836            .unwrap()
2837            .iter()
2838            .map(|(_, message)| message.id.clone())
2839            .collect();
2840        assert_eq!(
2841            order,
2842            vec![
2843                ack.id.clone(),
2844                second_ack.id.clone(),
2845                data[0].id.clone(),
2846                data[1].id.clone(),
2847                data[2].id.clone()
2848            ],
2849            "acknowledgements stop retry timers; queued behind data they arrived too late"
2850        );
2851    }
2852}