Skip to main content

offline_protocol/
transport_manager.rs

1//! Transport management for multi-transport protocol.
2//!
3//! This module provides the TransportManager which manages multiple transports
4//! and uses DORS (Dynamic Offline Relay Switch) to select the optimal transport
5//! for each message.
6
7use crate::constants::{HOP_COUNT_EMA_ALPHA, LATENCY_EMA_ALPHA, OBSERVED_STATS_COMPACT_THRESHOLD};
8use crate::events::{DorsEscalationPhase, DorsEscalationReasonCode, DorsReasonCode, Event};
9use crate::telemetry::routing::{RoutingDecision, RoutingPhase, RoutingReasonCode};
10use crate::{Error, Result};
11use chrono::Utc;
12use offline_protocol_core::time::Instant;
13use offline_protocol_core::{Message, Username, WireCodec};
14use offline_protocol_router::{
15    display_routing_score, DorsConfig, EscalationTriggerReason, TransportScore, TransportSelector,
16};
17use offline_protocol_transport::{
18    nostr::ResolveRequest, Error as TransportError, Transport, TransportMetrics, TransportStatus,
19    TransportType,
20};
21use std::collections::{HashMap, HashSet};
22use std::sync::Arc;
23use tracing::{debug, warn};
24
25/// Manages multiple transports and handles transport selection.
26pub struct TransportManager {
27    /// Available transports mapped by type.
28    ///
29    /// Transports are fully interior-mutable (every `Transport` method takes
30    /// `&self`), so they are shared as plain `Arc`s — no outer mutex
31    /// serializing sends against status/metrics reads.
32    transports: HashMap<TransportType, Arc<dyn Transport>>,
33
34    /// Current active transport.
35    current_transport: Option<TransportType>,
36
37    /// Transport selector (DORS).
38    selector: TransportSelector,
39
40    /// Locally observed delivery outcomes used to enrich transport metrics.
41    observations: HashMap<TransportType, ObservedStats>,
42
43    /// Optional callback for DORS lifecycle/decision events (OFF-258).
44    dors_event_callback: Option<Arc<dyn Fn(Event) + Send + Sync>>,
45
46    /// Optional callback for structured routing decisions. Fires alongside
47    /// every legacy `dors_event_callback` emission so the telemetry sink
48    /// receives the richer `RoutingDecision` shape while `EventCallback`
49    /// consumers continue to see the flattened `Event::Dors*` variants.
50    routing_decision_callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
51
52    /// When `true`, every emitted [`RoutingDecision`] carries the full
53    /// per-transport [`TransportScore`] breakdown in its `scores` field.
54    /// Mirrors `TelemetryConfig::routing_diagnostic` and is toggled by
55    /// `OfflineProtocol::install_telemetry_sink`.
56    routing_diagnostic: bool,
57
58    /// Last escalation trigger event emitted (reason, time) for dedupe window.
59    last_escalation_trigger_emitted: Option<(
60        DorsEscalationReasonCode,
61        offline_protocol_core::time::Instant,
62    )>,
63
64    /// Peers known to decode the binary wire codec (from their signed key
65    /// package). Messages addressed to a listed peer are stamped
66    /// [`WireCodec::BinaryV1`] at send; all others stay JSON.
67    peer_binary_wire: HashSet<String>,
68
69    /// Host-reported battery level for this device, if the platform publishes
70    /// one. Merged into every transport's metrics that does not report its own
71    /// — see [`TransportManager::set_device_battery`].
72    device_battery: Option<u8>,
73
74    /// Host-reported charging state, applied alongside [`Self::device_battery`].
75    device_is_charging: bool,
76}
77
78/// Carriers whose links are peer-to-peer, in the order a forwarding caller
79/// should prefer them.
80///
81/// These are the only transports that can carry a *mesh hop*: a frame handed
82/// straight to a neighbor's radio. Wi-Fi Direct leads because a link that
83/// exists at all is the higher-bandwidth one; BLE is the carrier that is
84/// almost always present. Everything else reaches peers through
85/// infrastructure, which is not a hop — it is an exit from the mesh.
86const MESH_TRANSPORTS: &[TransportType] = &[TransportType::WiFiDirect, TransportType::BLE];
87
88/// A peer reachable over a direct mesh link.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct MeshNeighbor {
91    /// User-level id of the neighbor.
92    pub peer_id: String,
93    /// Signal strength for the link, when the carrier measures one.
94    pub rssi: Option<i16>,
95    /// The carrier holding the link.
96    pub transport: TransportType,
97}
98
99impl MeshNeighbor {
100    /// How good this link is, on a 0–100 scale.
101    ///
102    /// Derived from signal strength where the carrier reports it, and a
103    /// mid-scale value where it does not — an unmeasured link should not
104    /// outrank a measured good one, nor be discarded like a measured bad one.
105    pub fn link_quality(&self) -> u8 {
106        match self.rssi {
107            Some(rssi) => offline_protocol_transport::LinkQuality::from_rssi(rssi).value(),
108            None => offline_protocol_transport::DEFAULT_LINK_QUALITY_WITHOUT_RSSI,
109        }
110    }
111}
112
113/// Dedupe window: don't re-emit same escalation trigger reason within this duration.
114const ESCALATION_TRIGGER_DEDUPE_SECS: u64 = 30;
115
116/// Cap on the per-peer binary-wire capability set. Keyed by the wire-claimed
117/// peer id, so — like `key_package_sent_to` — it is cleared at capacity to bound
118/// a forged-sender flood; the only cost of forgetting a peer is re-learning its
119/// capability from the next key package (one JSON send in the meantime).
120const MAX_PEER_BINARY_WIRE_ENTRIES: usize = 4096;
121
122/// Whether the platform reports each send's outcome back to this transport,
123/// so its own `success_count` / `failure_count` are real delivery evidence.
124/// Exhaustive on purpose: a new transport must decide which side it is on.
125fn has_send_confirmation_loop(transport_type: TransportType) -> bool {
126    match transport_type {
127        TransportType::Internet | TransportType::Reticulum | TransportType::Nostr => true,
128        TransportType::BLE | TransportType::WiFiDirect => false,
129    }
130}
131
132#[derive(Debug, Default, Clone)]
133struct ObservedStats {
134    success_count: u32,
135    failure_count: u32,
136    latency_ema: Option<f32>,
137    hop_ema: Option<f32>,
138    last_success_at: Option<Instant>,
139}
140
141impl ObservedStats {
142    fn record_success(&mut self, latency_ms: u32, hop_count: u8) {
143        self.success_count = self.success_count.saturating_add(1);
144        self.latency_ema = Some(update_ema(
145            self.latency_ema,
146            latency_ms as f32,
147            LATENCY_EMA_ALPHA,
148        ));
149        self.hop_ema = Some(update_ema(
150            self.hop_ema,
151            hop_count as f32,
152            HOP_COUNT_EMA_ALPHA,
153        ));
154        self.last_success_at = Some(Instant::now());
155        self.compact();
156    }
157
158    fn record_failure(&mut self) {
159        self.failure_count = self.failure_count.saturating_add(1);
160        self.compact();
161    }
162
163    fn compact(&mut self) {
164        let total = self.success_count.saturating_add(self.failure_count);
165        if total > OBSERVED_STATS_COMPACT_THRESHOLD {
166            self.success_count /= 2;
167            self.failure_count /= 2;
168        }
169    }
170
171    fn delivery_ratio(&self) -> Option<f32> {
172        let total = self.success_count + self.failure_count;
173        if total == 0 {
174            None
175        } else {
176            Some(self.success_count as f32 / total as f32)
177        }
178    }
179
180    fn drop_ratio(&self) -> Option<f32> {
181        self.delivery_ratio()
182            .map(|ratio| (1.0 - ratio).clamp(0.0, 1.0))
183    }
184
185    /// Merges the engine's delivery observations into `metrics`.
186    ///
187    /// A transport with a send confirmation loop (Internet, Reticulum,
188    /// Nostr: the platform reports each send's outcome back) owns its
189    /// success and failure counts, and they are kept when it has any. Every
190    /// other transport has no outcome of its own to report, so the engine's
191    /// acknowledgements are its delivery evidence and always win. The
192    /// previous rule was value-based ("keep the transport's counts when they
193    /// are non-zero"), so one stray count from BLE's reassembly path silently
194    /// discarded every later acknowledgement for that transport (#557).
195    fn apply_to_metrics(&self, transport_type: TransportType, metrics: &mut TransportMetrics) {
196        if self.success_count + self.failure_count > 0 {
197            let transport_owns_counts = has_send_confirmation_loop(transport_type)
198                && metrics.success_count + metrics.failure_count > 0;
199            if !transport_owns_counts {
200                metrics.success_count = self.success_count;
201                metrics.failure_count = self.failure_count;
202                metrics.delivery_ratio = self.delivery_ratio();
203                metrics.drop_rate = self.drop_ratio();
204            }
205        }
206
207        if let Some(latency) = self.latency_ema {
208            metrics.latency_ms = Some(latency.round().clamp(0.0, u32::MAX as f32) as u32);
209        }
210
211        if let Some(hop) = self.hop_ema {
212            metrics.average_hop_count = Some(hop);
213        }
214    }
215}
216
217fn update_ema(current: Option<f32>, new_value: f32, alpha: f32) -> f32 {
218    let alpha = alpha.clamp(0.0, 1.0);
219    match current {
220        Some(existing) => existing * (1.0 - alpha) + new_value * alpha,
221        None => new_value,
222    }
223}
224
225impl TransportManager {
226    /// Creates a new transport manager with default configuration.
227    pub fn new(selector: TransportSelector) -> Self {
228        Self {
229            transports: HashMap::new(),
230            current_transport: None,
231            selector,
232            observations: HashMap::new(),
233            dors_event_callback: None,
234            routing_decision_callback: None,
235            routing_diagnostic: false,
236            last_escalation_trigger_emitted: None,
237            peer_binary_wire: HashSet::new(),
238            device_battery: None,
239            device_is_charging: false,
240        }
241    }
242
243    /// Records the host's battery reading for this device.
244    ///
245    /// Battery is a property of the *device*, not of any one radio, but every
246    /// consumer inside the SDK reads it from the per-transport
247    /// [`TransportMetrics`] map: DORS energy scoring, the relay
248    /// promote/demote policy, the forwarding battery floor, and the telemetry
249    /// device-capability snapshot all resolve it through
250    /// `device_battery_from_available`. This setter is therefore the single
251    /// host-facing feed, and it is merged into the two snapshot loops that
252    /// build that map.
253    ///
254    /// `level` is clamped to 0-100. A transport that reports its own battery
255    /// level keeps it: the merge fills the field, never overwrites it.
256    pub fn set_device_battery(&mut self, level: u8, is_charging: bool) {
257        self.device_battery = Some(level.min(100));
258        self.device_is_charging = is_charging;
259    }
260
261    /// Returns the host-reported `(battery_level, is_charging)` pair, if the
262    /// platform has published one.
263    pub fn device_battery(&self) -> (Option<u8>, bool) {
264        (self.device_battery, self.device_is_charging)
265    }
266
267    /// Fills the device-level battery fields on one transport's metrics.
268    ///
269    /// **Fill-if-absent, never overwrite.** A transport that publishes its own
270    /// `battery_level` is reporting something the host feed cannot know better
271    /// (a peripheral's own cell, say), so its reading wins; the host value is
272    /// the fallback for the ordinary case where no transport reports one at
273    /// all. `is_charging` is only applied alongside a battery level that came
274    /// from the host, so a transport's own (level, charging) pair is never
275    /// split across two sources — the same coherence rule
276    /// `device_battery_from_available` applies when choosing between
277    /// transports.
278    fn apply_device_battery(&self, metrics: &mut TransportMetrics) {
279        let Some(level) = self.device_battery else {
280            return;
281        };
282        if metrics.battery_level.is_none() {
283            metrics.battery_level = Some(level);
284            metrics.is_charging = self.device_is_charging;
285        }
286    }
287
288    /// Returns whether a peer is recorded as able to decode the binary wire
289    /// codec (and would therefore be sent binary frames).
290    pub fn peer_supports_binary_wire(&self, peer_id: &str) -> bool {
291        self.peer_binary_wire.contains(peer_id)
292    }
293
294    /// Records whether a peer can decode our binary wire frames, learned from
295    /// its signed key package. Messages addressed to a capable peer are stamped
296    /// [`WireCodec::BinaryV1`] at send; all others stay JSON.
297    pub fn mark_peer_binary_wire(&mut self, peer_id: &str, supported: bool) {
298        if supported {
299            // Bound the wire-keyed set: clear at capacity, then insert.
300            if !self.peer_binary_wire.contains(peer_id)
301                && self.peer_binary_wire.len() >= MAX_PEER_BINARY_WIRE_ENTRIES
302            {
303                self.peer_binary_wire.clear();
304            }
305            self.peer_binary_wire.insert(peer_id.to_string());
306        } else {
307            self.peer_binary_wire.remove(peer_id);
308        }
309    }
310
311    /// Forwards a peer's advertised Nostr public key to the Nostr transport, so
312    /// gift wraps addressed to them are sealed to a key only their install
313    /// holds instead of to the publicly computable bootstrap key.
314    ///
315    /// `None` means "this peer advertises no Nostr key", which **forgets** any
316    /// key already held rather than leaving it in place. That is what keeps the
317    /// in-memory copy consistent with the durable record, whose downgrade
318    /// semantics delete on an empty advertisement: retaining it here instead
319    /// would keep sealing to a key the peer has stopped claiming, and survive
320    /// only until the next restart — a divergence with no symptom.
321    ///
322    /// A no-op when the Nostr transport is not installed (the common case —
323    /// `nostr_enabled` defaults off). The bound on the underlying map lives in
324    /// the transport.
325    pub fn mark_peer_nostr_pubkey(&mut self, peer_id: &str, pubkey_hex: Option<&str>) {
326        let Some(transport) = self.transports.get(&TransportType::Nostr) else {
327            return;
328        };
329        if let Some(nostr) = transport
330            .as_any()
331            .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
332        {
333            match pubkey_hex {
334                Some(pubkey_hex) => nostr.set_peer_nostr_pubkey(peer_id, pubkey_hex),
335                None => nostr.forget_peer_nostr_pubkey(peer_id),
336            }
337        }
338    }
339
340    /// Forgets a peer's Nostr public key, so frames to them revert to the
341    /// publicly computable bootstrap key.
342    ///
343    /// Used by the unblock clean slate, which declares everything learned about
344    /// a peer stale. Reverting is the right fallback rather than refusing to
345    /// send: the bootstrap key is derived from the peer's user id, so it works
346    /// whatever install they are on now.
347    pub fn clear_peer_nostr_pubkey(&mut self, peer_id: &str) {
348        if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
349            t.as_any()
350                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
351        }) {
352            nostr.forget_peer_nostr_pubkey(peer_id);
353        }
354    }
355
356    /// This install's Nostr public key, for advertising in outgoing key
357    /// packages. `None` when the Nostr transport is not installed.
358    pub fn nostr_public_key(&self) -> Option<String> {
359        self.transports
360            .get(&TransportType::Nostr)?
361            .as_any()
362            .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
363            .map(|nostr| nostr.public_key_hex())
364    }
365
366    /// Applies the sealed-envelope kill switch to the Nostr transport.
367    pub fn set_nostr_sealing_enabled(&mut self, enabled: bool) {
368        if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
369            t.as_any()
370                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
371        }) {
372            nostr.set_sealing_enabled(enabled);
373        }
374    }
375
376    /// Applies the cold-contact kill switch to the Nostr transport.
377    pub fn set_nostr_cold_contact_enabled(&mut self, enabled: bool) {
378        if let Some(nostr) = self.nostr_transport() {
379            nostr.set_cold_contact_enabled(enabled);
380        }
381    }
382
383    /// Queues a key-package record for publication at `slot_id`.
384    ///
385    /// A no-op when the Nostr transport is not installed, which is the common
386    /// case: publication is driven from the engine's tick, and that tick runs
387    /// whether or not Nostr is enabled.
388    ///
389    /// Serializes through the transport's own chokepoint, which yields JSON for
390    /// an unstamped message — and a published record must never be anything
391    /// else. The binary codec is negotiated per peer from that peer's key
392    /// package, and the reader of this record is by definition a stranger whose
393    /// key package we have not seen. Emitting binary here would make the record
394    /// unreadable by exactly the audience it exists for.
395    pub fn publish_nostr_key_package(&mut self, slot_id: &str, message: &Message) -> Result<()> {
396        let Some(nostr) = self.nostr_transport() else {
397            return Ok(());
398        };
399        let payload = nostr.serialize_message(message)?;
400        nostr.publish_key_package(slot_id, payload);
401        Ok(())
402    }
403
404    /// Drains the slot ids whose publication never reached a relay.
405    ///
406    /// Empty when Nostr is not installed, which keeps the caller's tick free of
407    /// a transport check it would otherwise need.
408    pub fn take_failed_nostr_publications(&self) -> Vec<String> {
409        self.nostr_transport()
410            .map(|nostr| nostr.take_failed_publications())
411            .unwrap_or_default()
412    }
413
414    /// Whether the Nostr transport is installed and publishing records.
415    pub fn nostr_cold_contact_active(&self) -> bool {
416        self.nostr_transport()
417            .map(|nostr| nostr.cold_contact_enabled())
418            .unwrap_or(false)
419    }
420
421    /// Whether asking the relays for `peer_id`'s published key packages would
422    /// queue a query now. `false` when Nostr is not installed. Reads only.
423    pub fn nostr_resolution_due(&self, peer_id: &str) -> bool {
424        self.nostr_transport()
425            .is_some_and(|nostr| nostr.resolution_due(peer_id))
426    }
427
428    /// Asks the relays for `peer_id`'s published key packages. Returns whether
429    /// a query was queued; `false` when Nostr is not installed.
430    pub fn request_nostr_key_packages(&self, peer_id: &str) -> bool {
431        self.nostr_transport()
432            .is_some_and(|nostr| nostr.request_peer_key_packages(peer_id))
433    }
434
435    /// Enables or disables username discovery on the Nostr transport.
436    pub fn set_nostr_username_discovery_enabled(&mut self, enabled: bool) {
437        if let Some(nostr) = self.nostr_transport() {
438            nostr.set_discovery_enabled(enabled);
439        }
440    }
441
442    /// Whether the Nostr transport is installed and username discovery is on.
443    ///
444    /// Already accounts for the cold-contact coupling: the transport reports
445    /// false when either switch is off.
446    pub fn nostr_discovery_active(&self) -> bool {
447        self.nostr_transport()
448            .map(|nostr| nostr.discovery_enabled())
449            .unwrap_or(false)
450    }
451
452    /// Queues this install's username claim for publication.
453    pub fn publish_nostr_discovery_record(&mut self, username: Username, payload: Vec<u8>) {
454        if let Some(nostr) = self.nostr_transport() {
455            nostr.publish_discovery_record(username, payload);
456        }
457    }
458
459    /// Queues a retraction of this install's username claim.
460    pub fn retract_nostr_discovery_record(&mut self, username: Username, payload: Vec<u8>) {
461        if let Some(nostr) = self.nostr_transport() {
462            nostr.retract_discovery_record(username, payload);
463        }
464    }
465
466    /// Drains the discovery tags whose publication never reached a relay.
467    pub fn take_failed_nostr_discovery_publications(&self) -> Vec<String> {
468        self.nostr_transport()
469            .map(|nostr| nostr.take_failed_discovery_publications())
470            .unwrap_or_default()
471    }
472
473    /// Drains the discovery tags whose publication a relay acknowledged.
474    pub fn take_confirmed_nostr_discovery_publications(&self) -> Vec<String> {
475        self.nostr_transport()
476            .map(|nostr| nostr.take_confirmed_discovery_publications())
477            .unwrap_or_default()
478    }
479
480    /// Whether a Nostr transport is installed at all.
481    ///
482    /// Distinct from [`Self::nostr_discovery_active`]: a retraction is owed
483    /// whether or not discovery is still switched on, so the engine has to be
484    /// able to tell "the feature is off" from "there is nothing here that could
485    /// carry a tombstone". Without the second, queueing a retraction silently
486    /// does nothing while the engine records it as done.
487    pub fn has_nostr_transport(&self) -> bool {
488        self.nostr_transport().is_some()
489    }
490
491    /// Requests resolution of the devices claiming `username`.
492    ///
493    /// A missing Nostr transport reports `Disabled`, which is what it is from
494    /// the caller's side: no query was sent and no event will follow.
495    pub fn resolve_nostr_username(&self, username: Username) -> ResolveRequest {
496        self.nostr_transport()
497            .map(|nostr| nostr.resolve_username(username))
498            .unwrap_or(ResolveRequest::Disabled)
499    }
500
501    /// Withdraws a queued username lookup that was never issued to a relay.
502    ///
503    /// Returns whether one was removed. Called when the engine gives up on a
504    /// lookup, so the name does not sit in the queue making every later request
505    /// for it return "already queued" without ever answering.
506    pub fn cancel_nostr_username_resolution(&self, username: &Username) -> bool {
507        self.nostr_transport()
508            .map(|nostr| nostr.cancel_username_resolution(username))
509            .unwrap_or(false)
510    }
511
512    fn nostr_transport(&self) -> Option<&offline_protocol_transport::nostr::NostrTransport> {
513        self.transports.get(&TransportType::Nostr).and_then(|t| {
514            t.as_any()
515                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
516        })
517    }
518
519    /// Sets the callback for DORS decision events (dors_score_updated, dors_transport_selected,
520    pub fn set_dors_event_callback(&mut self, callback: Option<Arc<dyn Fn(Event) + Send + Sync>>) {
521        self.dors_event_callback = callback;
522    }
523
524    /// Sets the callback that receives structured
525    /// [`crate::telemetry::RoutingDecision`] records.
526    ///
527    /// Fires at the same sites as `dors_event_callback`, carrying a richer
528    /// shape suitable for the unified telemetry sink.
529    ///
530    /// Crate-private: the callback is installed together with the telemetry
531    /// sink (`OfflineProtocol::enable_telemetry` installs the pipe's) and is
532    /// never wired directly, which keeps
533    /// [`crate::telemetry::TelemetryConfig`] the single source of truth for
534    /// its configuration.
535    pub(crate) fn set_routing_decision_callback(
536        &mut self,
537        callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
538    ) {
539        self.routing_decision_callback = callback;
540    }
541
542    /// Enables or disables the routing-diagnostic detail level.
543    ///
544    /// When `true`, [`RoutingDecision::scores`] is populated with the full
545    /// seven-factor [`TransportScore`] for every ranked transport on every
546    /// emission. When `false` (default), the vector is empty and the hot
547    /// path avoids the per-emission allocation. Mirrors
548    /// `TelemetryConfig::routing_diagnostic`.
549    ///
550    /// Crate-private: see [`Self::set_routing_decision_callback`].
551    pub(crate) fn set_routing_diagnostic(&mut self, enabled: bool) {
552        self.routing_diagnostic = enabled;
553    }
554
555    fn emit_dors_event(&self, event: Event) {
556        if let Some(ref cb) = self.dors_event_callback {
557            cb(event);
558        }
559    }
560
561    /// Lazy-emit a [`RoutingDecision`]. The `build` closure only runs when a
562    /// callback is actually installed, so the no-sink path pays only an
563    /// `Option::is_some` check — not a `Utc::now()` call and struct build.
564    fn emit_routing_decision<F: FnOnce() -> RoutingDecision>(&self, build: F) {
565        if let Some(ref cb) = self.routing_decision_callback {
566            cb(build());
567        }
568    }
569
570    fn escalation_trigger_reason_to_code(r: EscalationTriggerReason) -> DorsEscalationReasonCode {
571        match r {
572            EscalationTriggerReason::RetryThreshold => DorsEscalationReasonCode::RetryThreshold,
573            EscalationTriggerReason::PoorSignal => DorsEscalationReasonCode::PoorSignal,
574            EscalationTriggerReason::Congestion => DorsEscalationReasonCode::Congestion,
575            EscalationTriggerReason::LowTtl => DorsEscalationReasonCode::LowTtl,
576            EscalationTriggerReason::LowSuccessRate => DorsEscalationReasonCode::LowSuccessRate,
577        }
578    }
579
580    fn dors_reason_code_to_routing(code: DorsReasonCode) -> RoutingReasonCode {
581        match code {
582            DorsReasonCode::InitialSelection => RoutingReasonCode::InitialSelection,
583            DorsReasonCode::PrimarySelected => RoutingReasonCode::PrimarySelected,
584            DorsReasonCode::PrimarySuccess => RoutingReasonCode::PrimarySuccess,
585            DorsReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
586            DorsReasonCode::EscalationApplied => RoutingReasonCode::EscalationApplied,
587            DorsReasonCode::CurrentUnavailable => RoutingReasonCode::CurrentUnavailable,
588        }
589    }
590
591    fn escalation_reason_code_to_routing(code: DorsEscalationReasonCode) -> RoutingReasonCode {
592        match code {
593            DorsEscalationReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
594            DorsEscalationReasonCode::RetryThreshold => RoutingReasonCode::RetryThreshold,
595            DorsEscalationReasonCode::PoorSignal => RoutingReasonCode::PoorSignal,
596            DorsEscalationReasonCode::Congestion => RoutingReasonCode::Congestion,
597            DorsEscalationReasonCode::LowTtl => RoutingReasonCode::LowTtl,
598            DorsEscalationReasonCode::LowSuccessRate => RoutingReasonCode::LowSuccessRate,
599        }
600    }
601
602    /// Builds a [`RoutingDecision`]. The `detailed_scores` slice is cloned
603    /// into `scores` when `routing_diagnostic` is enabled on the manager;
604    /// otherwise the vector stays empty (allocation-free).
605    ///
606    /// Takes `now_ms` by argument rather than calling `Utc::now()` per
607    /// build so every decision emitted from a single `send()` cycle shares
608    /// one timestamp — easier to correlate downstream and saves three to
609    /// four syscalls on the send hot path.
610    #[allow(clippy::too_many_arguments)]
611    fn build_routing_decision(
612        &self,
613        now_ms: i64,
614        phase: RoutingPhase,
615        from: Option<TransportType>,
616        to: Option<TransportType>,
617        winning_score: Option<f32>,
618        reason_code: Option<RoutingReasonCode>,
619        detailed_scores: Option<&[(TransportType, TransportScore)]>,
620    ) -> RoutingDecision {
621        // Sanitize the internal fallback-demotion sentinel out of every score
622        // that leaves the engine. DORS keeps Internet's `total` negative to rank
623        // it last; telemetry/UniFFI consumers must see the real 0–125 quality
624        // score instead. This is the single chokepoint for all RoutingDecision
625        // emitters, so `winning_score` is also sanitized here defensively
626        // (idempotent for an already-positive value).
627        let scores = match (self.routing_diagnostic, detailed_scores) {
628            (true, Some(detailed)) => detailed
629                .iter()
630                .map(|(t, s)| {
631                    let mut s = s.clone();
632                    s.total = display_routing_score(s.total);
633                    (*t, s)
634                })
635                .collect(),
636            _ => Vec::new(),
637        };
638        RoutingDecision {
639            timestamp_ms: now_ms,
640            phase,
641            from,
642            to,
643            winning_score: winning_score.map(display_routing_score),
644            reason_code,
645            scores,
646        }
647    }
648
649    /// Emit dors_escalation_triggered at trigger boundary (typed reason), deduped by reason + time window.
650    fn emit_escalation_trigger_if_deduped(
651        &mut self,
652        now_ms: i64,
653        reason_code: DorsEscalationReasonCode,
654        from: TransportType,
655        to: TransportType,
656        reason_detail: Option<String>,
657        detailed_scores: Option<&[(TransportType, TransportScore)]>,
658    ) {
659        let now = offline_protocol_core::time::Instant::now();
660        let emit = match &self.last_escalation_trigger_emitted {
661            Some((last_code, last_at)) => {
662                *last_code != reason_code
663                    || last_at.elapsed().as_secs() >= ESCALATION_TRIGGER_DEDUPE_SECS
664            }
665            None => true,
666        };
667        if emit {
668            self.last_escalation_trigger_emitted = Some((reason_code, now));
669            self.emit_dors_event(Event::dors_escalation_triggered(
670                DorsEscalationPhase::Triggered,
671                from.to_string(),
672                to.to_string(),
673                reason_code,
674                reason_detail,
675            ));
676            self.emit_routing_decision(|| {
677                self.build_routing_decision(
678                    now_ms,
679                    RoutingPhase::Escalated,
680                    Some(from),
681                    Some(to),
682                    None,
683                    Some(Self::escalation_reason_code_to_routing(reason_code)),
684                    detailed_scores,
685                )
686            });
687        }
688    }
689
690    /// Adds a transport to the manager.
691    ///
692    /// # Arguments
693    ///
694    /// * `transport_type` - Type of transport to add
695    /// * `transport` - The transport implementation
696    pub fn add_transport(&mut self, transport_type: TransportType, transport: Box<dyn Transport>) {
697        self.transports.insert(transport_type, Arc::from(transport));
698    }
699
700    /// Sends a message through the best available transport, with fallback.
701    ///
702    /// DORS selects a primary transport (applying hysteresis, cooldown, and
703    /// stability checks). If the primary's `send()` fails synchronously, the
704    /// remaining transports are tried in descending score order before
705    /// returning an error. Each synchronous failure is recorded via
706    /// `record_retry_failure` so DORS can adjust future scoring.
707    ///
708    /// # Internet transport — fallback does NOT apply
709    ///
710    /// Internet `send()` enqueues the message and returns `Ok(())`
711    /// immediately. The actual wire-level outcome is reported asynchronously
712    /// via the `confirm_sent` / `report_send_failure` confirmation loop on
713    /// `InternetTransport`. Because `send()` never fails synchronously, the
714    /// fallback loop below will **not** trigger for Internet. If the
715    /// WebSocket is silently broken (status still `Available` but sends
716    /// fail), messages will be enqueued, detected as failures by the
717    /// confirmation-loop timeout, and counted in DORS metrics — but they
718    /// are **not** retried on an alternative transport by this method.
719    /// Retry/re-routing for those messages is handled by the higher-level
720    /// outbox retry mechanism.
721    pub fn send(&mut self, message: &Message) -> Result<()> {
722        // Stamp the negotiated wire codec for this recipient. The clone happens
723        // only on the binary path (peers known to support it); JSON and unknown
724        // peers reuse the borrowed message. The internet transport ignores the
725        // stamp (its relay bridge is JSON-only), and mesh transports only ever
726        // emit to a recipient that is a directly connected peer, so `recipient`
727        // is the physical peer whose capability was recorded — keying by it
728        // matches the same peer-string the MTU path already keys by.
729        //
730        // The codec travels on the message, and both `Transport::send` and this
731        // method take `&Message` as public API, so carrying the stamp across
732        // that boundary needs an owned message — hence this clone. Note it is a
733        // full-message clone: it copies `binary_content` (up to the max message
734        // size), not just the envelope, so for media-sized payloads sent to a
735        // binary-capable peer it is a real payload memcpy on the hot path. It is
736        // a deliberate tradeoff: threading the codec as a separate argument would
737        // shave the clone but is a breaking change to the public `Transport`
738        // trait every downstream implementor relies on. The transport's own
739        // queue-clone is unaffected either way. If profiling ever flags this,
740        // stamp via a lightweight wrapper rather than cloning the payload.
741        let stamped = self.stamp_negotiated_wire_codec(message);
742        let message = stamped.as_ref().unwrap_or(message);
743
744        let available = self.get_available_transports();
745        if available.is_empty() {
746            return Err(Error::Transport(TransportError::TransportNotAvailable(
747                "No available transport".to_string(),
748            )));
749        }
750
751        // Single timestamp shared by every RoutingDecision emitted from this
752        // send cycle — cheaper than per-emit `Utc::now()` and lets downstream
753        // consumers group decisions by timestamp.
754        let now_ms = Utc::now().timestamp_millis();
755
756        let previous = self.current_transport;
757
758        // DORS selects the primary transport (applies hysteresis/cooldown/stability).
759        let primary = self
760            .selector
761            .select_transport(message, &available)
762            .ok_or_else(|| {
763                Error::Transport(TransportError::TransportNotAvailable(
764                    "No suitable transport selected from available transports".to_string(),
765                ))
766            })?;
767
768        // Emit DORS observability: scores and selection (before send attempt).
769        // At the default (non-diagnostic) tier we project the detailed
770        // breakdown down to `(transport, total)` — a single scoring pass
771        // feeds both the legacy event and the routing record.
772        let detailed_scores: Option<Vec<(TransportType, TransportScore)>> =
773            if self.routing_diagnostic && self.routing_decision_callback.is_some() {
774                Some(self.selector.score_and_rank_detailed(message, &available))
775            } else {
776                None
777            };
778        let detailed_slice = detailed_scores.as_deref();
779        let scored: Vec<(TransportType, f32)> = match detailed_slice {
780            Some(d) => d.iter().map(|(t, s)| (*t, s.total)).collect(),
781            None => self.selector.score_and_rank(message, &available),
782        };
783        // Sanitize the fallback-demotion sentinel out of the emitted scores (the
784        // `scored` vec itself keeps the raw totals — the fallback loop below
785        // relies on Internet ranking last).
786        let scores: Vec<(String, f32)> = scored
787            .iter()
788            .map(|(t, s)| (t.to_string(), display_routing_score(*s)))
789            .collect();
790        self.emit_dors_event(Event::dors_score_updated(scores));
791
792        self.emit_routing_decision(|| {
793            self.build_routing_decision(
794                now_ms,
795                RoutingPhase::ScoreUpdated,
796                previous,
797                None,
798                None,
799                None,
800                detailed_slice,
801            )
802        });
803        // Telemetry-only; sanitize the fallback-demotion sentinel so the emitted
804        // selection score is the real 0–125 quality value, not the negative rank.
805        let primary_score = scored
806            .iter()
807            .find(|(t, _)| *t == primary)
808            .map(|(_, s)| display_routing_score(*s))
809            .unwrap_or(0.0);
810        let selection_reason = if previous.is_none() {
811            DorsReasonCode::InitialSelection
812        } else {
813            DorsReasonCode::PrimarySelected
814        };
815        self.emit_dors_event(Event::dors_transport_selected(
816            previous.map(|t| t.to_string()),
817            primary.to_string(),
818            selection_reason,
819            primary_score,
820        ));
821        self.emit_routing_decision(|| {
822            self.build_routing_decision(
823                now_ms,
824                RoutingPhase::Selected,
825                previous,
826                Some(primary),
827                Some(primary_score),
828                Some(Self::dors_reason_code_to_routing(selection_reason)),
829                detailed_slice,
830            )
831        });
832
833        // Try the primary transport first.
834        let mut primary_refusal = None;
835        let primary_result = {
836            let transport = self
837                .transports
838                .get(&primary)
839                .ok_or_else(|| Error::Other(format!("Transport {:?} not found", primary)))?;
840            transport.send(message)
841        };
842
843        match primary_result {
844            Ok(()) => {
845                self.current_transport = Some(primary);
846                if previous != Some(primary) {
847                    let reason_code = if previous.is_some_and(|p| !available.contains_key(&p)) {
848                        DorsReasonCode::CurrentUnavailable
849                    } else {
850                        DorsReasonCode::PrimarySuccess
851                    };
852                    self.emit_dors_event(Event::dors_transport_switched(
853                        previous.map(|t| t.to_string()),
854                        primary.to_string(),
855                        reason_code,
856                        None,
857                    ));
858                    self.emit_routing_decision(|| {
859                        self.build_routing_decision(
860                            now_ms,
861                            RoutingPhase::Switched,
862                            previous,
863                            Some(primary),
864                            Some(primary_score),
865                            Some(Self::dors_reason_code_to_routing(reason_code)),
866                            detailed_slice,
867                        )
868                    });
869                }
870                return Ok(());
871            }
872            Err(e) => {
873                let is_peer_routing = matches!(e, TransportError::PeerNotReachable(_));
874                if is_peer_routing {
875                    debug!(
876                        transport = ?primary,
877                        error = %e,
878                        "Peer not reachable via primary transport, trying fallback"
879                    );
880                    // Kept as the terminal error until a fallback replaces it.
881                    // Without it, a primary that refused the recipient with
882                    // nothing left to try ended as `SendFailed("All transports
883                    // failed")`, which every classifier reads as a carrier that
884                    // broke mid-send: the deferral said `transport_send_failed`
885                    // for a recipient no carrier could address.
886                    primary_refusal = Some(e);
887                } else {
888                    warn!(
889                        transport = ?primary,
890                        error = %e,
891                        "Primary transport send failed, trying fallback"
892                    );
893                    self.selector.record_retry_failure(primary);
894                }
895            }
896        }
897
898        // Primary failed — try remaining transports in score order.
899        // Reuse the scored ranking computed above; record_retry_failure only
900        // mutates retry_counts which score_and_rank does not read.
901        let mut last_error = primary_refusal;
902        let mut attempted: Vec<TransportType> = vec![primary];
903
904        for (transport_type, _score) in &scored {
905            if *transport_type == primary {
906                continue;
907            }
908
909            // Emit escalation trigger at DORS boundary (typed reason), deduped.
910            if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect {
911                if let Some(trigger_reason) = self.selector.escalation_trigger_reason() {
912                    let reason_code = Self::escalation_trigger_reason_to_code(trigger_reason);
913                    self.emit_escalation_trigger_if_deduped(
914                        now_ms,
915                        reason_code,
916                        primary,
917                        *transport_type,
918                        None,
919                        detailed_slice,
920                    );
921                }
922            }
923
924            let transport = match self.transports.get(transport_type) {
925                Some(t) => t,
926                None => continue,
927            };
928
929            attempted.push(*transport_type);
930
931            match transport.send(message) {
932                Ok(()) => {
933                    self.current_transport = Some(*transport_type);
934                    self.selector.set_current_transport(*transport_type);
935                    // Emit switch only when active transport actually changed (fallback success).
936                    if previous != Some(*transport_type) {
937                        let reason_code = if primary == TransportType::BLE
938                            && *transport_type == TransportType::WiFiDirect
939                        {
940                            DorsReasonCode::EscalationApplied
941                        } else {
942                            DorsReasonCode::FallbackSuccess
943                        };
944                        self.emit_dors_event(Event::dors_transport_switched(
945                            previous.map(|t| t.to_string()),
946                            transport_type.to_string(),
947                            reason_code,
948                            Some("primary send failed, fallback succeeded".to_string()),
949                        ));
950                        let fallback = *transport_type;
951                        self.emit_routing_decision(|| {
952                            self.build_routing_decision(
953                                now_ms,
954                                RoutingPhase::Switched,
955                                previous,
956                                Some(fallback),
957                                None,
958                                Some(Self::dors_reason_code_to_routing(reason_code)),
959                                detailed_slice,
960                            )
961                        });
962                    }
963                    // Escalation applied only when BLE→WiFi fallback actually succeeded.
964                    if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect
965                    {
966                        self.emit_dors_event(Event::dors_escalation_triggered(
967                            DorsEscalationPhase::Applied,
968                            primary.to_string(),
969                            transport_type.to_string(),
970                            DorsEscalationReasonCode::FallbackSuccess,
971                            Some("primary BLE send failed, fallback to WiFi succeeded".to_string()),
972                        ));
973                        let escalated = *transport_type;
974                        self.emit_routing_decision(|| {
975                            self.build_routing_decision(
976                                now_ms,
977                                RoutingPhase::Escalated,
978                                Some(primary),
979                                Some(escalated),
980                                None,
981                                Some(Self::escalation_reason_code_to_routing(
982                                    DorsEscalationReasonCode::FallbackSuccess,
983                                )),
984                                detailed_slice,
985                            )
986                        });
987                    }
988                    return Ok(());
989                }
990                Err(e) => {
991                    if !matches!(e, TransportError::PeerNotReachable(_)) {
992                        warn!(
993                            transport = ?transport_type,
994                            error = %e,
995                            "Fallback transport send failed, trying next"
996                        );
997                        self.selector.record_retry_failure(*transport_type);
998                    }
999                    last_error = Some(e);
1000                }
1001            }
1002        }
1003
1004        let terminal_error = last_error.unwrap_or_else(|| {
1005            TransportError::SendFailed(format!("All transports failed (tried {:?})", attempted))
1006        });
1007        Err(Error::Transport(terminal_error))
1008    }
1009
1010    /// Sends a message via a specific transport, bypassing DORS selection.
1011    ///
1012    /// # Arguments
1013    ///
1014    /// * `message` - The message to send
1015    /// * `transport_type` - The transport to use
1016    ///
1017    /// # Returns
1018    ///
1019    /// Returns Ok(()) if sent successfully, Err otherwise.
1020    pub fn send_via_transport(
1021        &mut self,
1022        message: &Message,
1023        transport_type: TransportType,
1024    ) -> Result<()> {
1025        let transport = self
1026            .transports
1027            .get(&transport_type)
1028            .ok_or_else(|| Error::Other(format!("Transport {:?} not found", transport_type)))?;
1029
1030        if transport.status() != TransportStatus::Available {
1031            return Err(Error::Other(format!(
1032                "Transport {:?} is not available",
1033                transport_type
1034            )));
1035        }
1036
1037        // Preserved as `Error::Transport`, not flattened into `Error::Other`.
1038        // Flattening discarded the variant every classifier keys on —
1039        // `SessionStateError::classify` and `Protocol::is_no_carrier_error`
1040        // both answer on `Error::Transport(_)`, so on this path they saw
1041        // `Unknown`/`false` and the rendered string was the only surviving
1042        // signal. That is exactly the string that must stop being read.
1043        // Stamped exactly as `send` does. This path carries ordinary DMs
1044        // whenever a recipient is a live mesh neighbour, so skipping the stamp
1045        // would downgrade binary-capable peers to JSON on the mesh while the
1046        // DORS path kept them on binary.
1047        let stamped = self.stamp_negotiated_wire_codec(message);
1048        let message = stamped.as_ref().unwrap_or(message);
1049        transport.send(message).map_err(Error::Transport)?;
1050
1051        // Only update current transport after successful send
1052        self.current_transport = Some(transport_type);
1053
1054        Ok(())
1055    }
1056
1057    /// Lists every peer reachable over a direct mesh link, with the transport
1058    /// that link runs on.
1059    ///
1060    /// Only the peer-to-peer carriers are consulted: a mesh neighbor is one we
1061    /// can hand a frame to over our own radio, which is what makes it a
1062    /// candidate next hop. Infrastructure carriers (Internet, Nostr, Reticulum)
1063    /// reach peers through something else, so they report no links and appear
1064    /// here as nothing — a message routed through them is not taking a mesh
1065    /// hop, it is leaving the mesh.
1066    ///
1067    /// A peer visible on more than one carrier is reported once, on the first
1068    /// carrier that claims it; [`Self::send_to_neighbor`] resolves the same way,
1069    /// so the pair stay consistent.
1070    pub fn mesh_neighbors(&self) -> Vec<MeshNeighbor> {
1071        let mut neighbors: Vec<MeshNeighbor> = Vec::new();
1072
1073        for transport_type in MESH_TRANSPORTS {
1074            let Some(transport) = self.transports.get(transport_type) else {
1075                continue;
1076            };
1077            for link in transport.connected_peers() {
1078                if neighbors.iter().any(|n| n.peer_id == link.peer_id) {
1079                    continue;
1080                }
1081                neighbors.push(MeshNeighbor {
1082                    peer_id: link.peer_id,
1083                    rssi: link.rssi,
1084                    transport: *transport_type,
1085                });
1086            }
1087        }
1088
1089        neighbors
1090    }
1091
1092    /// Returns an owned copy stamped with the negotiated binary codec, when
1093    /// this recipient has one and the message is not already stamped.
1094    ///
1095    /// `None` means the borrowed message is already correct, which is the
1096    /// common case (JSON peers, unknown peers, already-stamped messages) and
1097    /// costs no allocation.
1098    ///
1099    /// Shared by every send path rather than living inline in [`Self::send`].
1100    /// The codec is negotiated per recipient, so a path that skips the stamp
1101    /// silently downgrades a binary-capable peer to JSON — a regression with no
1102    /// error and no event, visible only as larger frames on the wire.
1103    fn stamp_negotiated_wire_codec(&self, message: &Message) -> Option<Message> {
1104        if message.wire_codec() == WireCodec::BinaryV1
1105            || !self.peer_binary_wire.contains(message.recipient.as_str())
1106        {
1107            return None;
1108        }
1109        let mut stamped = message.clone();
1110        stamped.set_wire_codec(WireCodec::BinaryV1);
1111        Some(stamped)
1112    }
1113
1114    /// The mesh carrier holding a live link straight to `peer_id`, if any.
1115    ///
1116    /// Ordered by mesh-carrier preference, so a peer reachable over both answers
1117    /// with the higher-bandwidth carrier. This is ground truth rather than a
1118    /// claim: it is read from the transport's live link view at the moment it
1119    /// is asked, never remembered.
1120    pub fn mesh_transport_for(&self, peer_id: &str) -> Option<TransportType> {
1121        MESH_TRANSPORTS.iter().copied().find(|transport_type| {
1122            self.transports
1123                .get(transport_type)
1124                .filter(|transport| transport.status() == TransportStatus::Available)
1125                .is_some_and(|transport| {
1126                    transport
1127                        .connected_peers()
1128                        .iter()
1129                        .any(|link| link.peer_id == peer_id)
1130                })
1131        })
1132    }
1133
1134    /// Whether the mesh carrier `transport_type` holds a live link straight
1135    /// to `peer_id`.
1136    ///
1137    /// [`Self::mesh_transport_for`] answers with the preferred carrier only,
1138    /// so a peer linked over both never names the second; this asks about one
1139    /// carrier. `false` for a carrier that is not registered, not available,
1140    /// or not a mesh carrier.
1141    pub fn holds_mesh_link(&self, transport_type: TransportType, peer_id: &str) -> bool {
1142        MESH_TRANSPORTS.contains(&transport_type)
1143            && self
1144                .transports
1145                .get(&transport_type)
1146                .filter(|transport| transport.status() == TransportStatus::Available)
1147                .is_some_and(|transport| {
1148                    transport
1149                        .connected_peers()
1150                        .iter()
1151                        .any(|link| link.peer_id == peer_id)
1152                })
1153    }
1154
1155    /// Every available carrier that does its own routing.
1156    ///
1157    /// The enumerated form of [`Self::has_infrastructure_carrier`], for callers
1158    /// that hold a per-recipient fact and must ask each carrier separately
1159    /// rather than accepting "one of them is up" for every recipient.
1160    pub fn available_infrastructure_carriers(&self) -> Vec<TransportType> {
1161        self.transports
1162            .iter()
1163            .filter(|(transport_type, transport)| {
1164                !MESH_TRANSPORTS.contains(transport_type)
1165                    && transport.status() == TransportStatus::Available
1166            })
1167            .map(|(transport_type, _)| *transport_type)
1168            .collect()
1169    }
1170
1171    /// Whether a frame for `peer_id` can plausibly arrive without being carried
1172    /// by other devices.
1173    ///
1174    /// This exists because **a send returning `Ok` is not evidence of
1175    /// reachability**. BLE and Wi-Fi Direct refuse a recipient they hold no
1176    /// link to, but the internet transport and Reticulum enqueue for any
1177    /// recipient and report success, so a frame handed to them for someone
1178    /// neither can reach is reported as sent and never arrives. Anything that
1179    /// decides "the mesh has to carry this" from a send failure therefore never
1180    /// fires on a device where one of those carriers is up. Asking this instead
1181    /// keeps that decision on facts we can check.
1182    ///
1183    /// Two ways the answer is yes:
1184    ///
1185    /// - An **infrastructure** carrier is available. Internet, Nostr and
1186    ///   Reticulum do their own routing and reach peers we hold no radio link
1187    ///   to, so a direct send is a real delivery attempt. (Reticulum counts
1188    ///   here because it is a routing network in its own right — its `Ok` means
1189    ///   "accepted for routing", which is as much as any relay-style carrier
1190    ///   promises.)
1191    /// - The peer is one of our own **mesh neighbors**, so a mesh carrier can
1192    ///   hand the frame straight over.
1193    ///
1194    /// Checked in that order so the common online case costs a status scan and
1195    /// never enumerates links.
1196    ///
1197    /// **This answer is deliberately peer-blind on its first arm**, and that is
1198    /// a real limit rather than an oversight: an infrastructure carrier being up
1199    /// says nothing about whether *this* recipient is on it. Callers that hold a
1200    /// peer-specific fact must not settle for this answer — the relay's own
1201    /// `recipient_unreachable` verdict is exactly such a fact, and the park it
1202    /// drives offers the frame to the mesh regardless of what this returns (see
1203    /// `park_unreachable_dm`).
1204    pub fn can_reach_without_carrying(&self, peer_id: &str) -> bool {
1205        self.has_infrastructure_carrier() || self.mesh_can_address(peer_id)
1206    }
1207
1208    /// Whether any carrier that does its own routing is available.
1209    ///
1210    /// Peer-blind by nature: these carriers reach peers we hold no radio link
1211    /// to, so there is no per-peer link to consult. Note the status is
1212    /// bridge-reported and means "the platform says this carrier is up", not
1213    /// "the relay connection is authenticated" — a distinction that matters
1214    /// wherever this stands in for reachability.
1215    pub fn has_infrastructure_carrier(&self) -> bool {
1216        self.transports.iter().any(|(transport_type, transport)| {
1217            !MESH_TRANSPORTS.contains(transport_type)
1218                && transport.status() == TransportStatus::Available
1219        })
1220    }
1221
1222    /// Ids of messages some carrier has accepted but not yet started writing
1223    /// (see [`Transport::queued_message_ids`]).
1224    pub fn queued_message_ids(&self) -> std::collections::HashSet<String> {
1225        self.transports
1226            .values()
1227            .flat_map(|transport| transport.queued_message_ids())
1228            .collect()
1229    }
1230
1231    /// Whether a mesh carrier holds a live link straight to `peer_id`.
1232    ///
1233    /// The peer-specific half of [`Self::can_reach_without_carrying`], split out
1234    /// because callers that already know infrastructure is up still need to ask
1235    /// whether the mesh can hand a frame over without anyone carrying it.
1236    pub fn mesh_can_address(&self, peer_id: &str) -> bool {
1237        self.mesh_transport_for(peer_id).is_some()
1238    }
1239
1240    /// Whether `transport_type` is a short-range carrier that can only address
1241    /// peers it holds a live link to — as opposed to an infrastructure carrier
1242    /// that does its own routing.
1243    ///
1244    /// A frame that arrived over one of these travelled either from a neighbor
1245    /// or through them, which is what makes it evidence about the route back.
1246    pub fn is_mesh_transport(transport_type: TransportType) -> bool {
1247        MESH_TRANSPORTS.contains(&transport_type)
1248    }
1249
1250    /// Whether `transport_type` can actually put a frame in front of `peer_id`.
1251    ///
1252    /// The narrower question [`Self::can_reach_without_carrying`] answers across
1253    /// every carrier, asked of one. It exists for the same reason: a transport
1254    /// returning `Ok` is not evidence the frame can arrive. A **mesh** carrier
1255    /// can only address a peer it holds a live link to: BLE and Wi-Fi Direct
1256    /// both refuse a send to anyone else, and asking first lets a caller choose
1257    /// another route before it spends a send, rather than after the refusal.
1258    /// **Infrastructure** carriers do their own routing, so for them the answer
1259    /// is yes whenever they are available.
1260    ///
1261    /// Callers that would otherwise trust a preferred transport — replying on
1262    /// the link a message arrived over, above all — must ask this first, or the
1263    /// reply dies on exactly the multi-hop path that made forwarding necessary.
1264    pub fn can_address_via(&self, transport_type: TransportType, peer_id: &str) -> bool {
1265        let Some(transport) = self.transports.get(&transport_type) else {
1266            return false;
1267        };
1268        if transport.status() != TransportStatus::Available {
1269            return false;
1270        }
1271        if !MESH_TRANSPORTS.contains(&transport_type) {
1272            return true;
1273        }
1274        transport
1275            .connected_peers()
1276            .iter()
1277            .any(|link| link.peer_id == peer_id)
1278    }
1279
1280    /// Hands `message` to a specific mesh neighbor, whatever the message's own
1281    /// recipient is.
1282    ///
1283    /// This is the forwarding primitive: the frame crosses one link, to the
1284    /// peer named by `peer_id`. Because that peer is the physical target, the
1285    /// binary wire codec is negotiated against **its** capabilities rather than
1286    /// the recipient's — stamping a forwarded frame binary because the distant
1287    /// recipient supports it could put bytes a neighbor cannot decode onto that
1288    /// neighbor's link.
1289    ///
1290    /// Returns the transport the frame went out on.
1291    pub fn send_to_neighbor(&self, peer_id: &str, message: &Message) -> Result<TransportType> {
1292        let stamped;
1293        let message = if message.wire_codec() != WireCodec::BinaryV1
1294            && self.peer_binary_wire.contains(peer_id)
1295        {
1296            let mut m = message.clone();
1297            m.set_wire_codec(WireCodec::BinaryV1);
1298            stamped = m;
1299            &stamped
1300        } else if message.wire_codec() == WireCodec::BinaryV1
1301            && !self.peer_binary_wire.contains(peer_id)
1302        {
1303            // The frame arrived stamped binary (or was stamped for a different
1304            // peer) but this hop cannot decode it. Fall back to the JSON floor
1305            // rather than writing bytes the neighbor will drop.
1306            let mut m = message.clone();
1307            m.set_wire_codec(WireCodec::Json);
1308            stamped = m;
1309            &stamped
1310        } else {
1311            message
1312        };
1313
1314        let mut last_error: Option<String> = None;
1315
1316        for transport_type in MESH_TRANSPORTS {
1317            let Some(transport) = self.transports.get(transport_type) else {
1318                continue;
1319            };
1320            if transport.status() != TransportStatus::Available {
1321                continue;
1322            }
1323            match transport.send_to_peer(peer_id, message) {
1324                Ok(()) => return Ok(*transport_type),
1325                Err(e) => last_error = Some(e.to_string()),
1326            }
1327        }
1328
1329        Err(Error::Transport(TransportError::PeerNotReachable(format!(
1330            "no mesh transport holds a link to {}{}",
1331            peer_id,
1332            last_error.map(|e| format!(" ({})", e)).unwrap_or_default()
1333        ))))
1334    }
1335
1336    /// Attempts to receive a message from any transport.
1337    ///
1338    /// # Returns
1339    ///
1340    /// Returns Ok(Some((TransportType, Message))) if a message was received, Ok(None) if no message available.
1341    pub fn receive(&self) -> Result<Option<(TransportType, Message)>> {
1342        // Check all transports for messages
1343        for (transport_type, transport) in &self.transports {
1344            match transport.receive() {
1345                Ok(Some(message)) => {
1346                    debug!(
1347                        transport = ?transport_type,
1348                        sender = %message.sender,
1349                        recipient = %message.recipient,
1350                        "TransportManager found message"
1351                    );
1352                    return Ok(Some((*transport_type, message)));
1353                }
1354                Ok(None) => {
1355                    // No message from this transport, continue checking others
1356                }
1357                Err(e) => {
1358                    warn!(
1359                        transport = ?transport_type,
1360                        error = %e,
1361                        "Transport receive error"
1362                    );
1363                }
1364            }
1365        }
1366        Ok(None)
1367    }
1368
1369    /// Gets metrics for all available transports.
1370    ///
1371    /// # Returns
1372    ///
1373    /// Returns a map of transport type to metrics.
1374    pub fn get_available_transports(&self) -> HashMap<TransportType, TransportMetrics> {
1375        let mut available = HashMap::new();
1376
1377        for (transport_type, transport) in &self.transports {
1378            let maybe_metrics = {
1379                let status = transport.status();
1380                if status == TransportStatus::Available {
1381                    Some(transport.metrics())
1382                } else {
1383                    debug!(
1384                        transport = ?transport_type,
1385                        status = ?status,
1386                        "Transport excluded from available set"
1387                    );
1388                    None
1389                }
1390            };
1391
1392            if let Some(mut metrics) = maybe_metrics {
1393                if let Some(stats) = self.observations.get(transport_type) {
1394                    stats.apply_to_metrics(*transport_type, &mut metrics);
1395                }
1396                self.apply_device_battery(&mut metrics);
1397                available.insert(*transport_type, metrics);
1398            }
1399        }
1400
1401        available
1402    }
1403
1404    /// Gets the current active transport type.
1405    pub fn current_transport(&self) -> Option<TransportType> {
1406        self.current_transport
1407    }
1408
1409    /// Returns the current status of every registered transport, including
1410    /// those not currently [`TransportStatus::Available`].
1411    ///
1412    /// Used by the telemetry aggregator to detect state transitions; unlike
1413    /// [`TransportManager::get_available_transports`], this only reads each
1414    /// transport's status with no metrics work.
1415    pub fn get_all_transport_statuses(&self) -> HashMap<TransportType, TransportStatus> {
1416        let mut out = HashMap::with_capacity(self.transports.len());
1417        for (transport_type, transport) in &self.transports {
1418            out.insert(*transport_type, transport.status());
1419        }
1420        out
1421    }
1422
1423    /// Returns the per-transport status map AND the available-only metrics
1424    /// map in a single pass.
1425    ///
1426    /// Equivalent to calling [`Self::get_all_transport_statuses`] and
1427    /// [`Self::get_available_transports`] back-to-back, but reads each
1428    /// transport's status once instead of twice. Used by the per-tick
1429    /// telemetry aggregator.
1430    pub fn snapshot_status_and_available(
1431        &self,
1432    ) -> (
1433        HashMap<TransportType, TransportStatus>,
1434        HashMap<TransportType, TransportMetrics>,
1435    ) {
1436        let mut statuses = HashMap::with_capacity(self.transports.len());
1437        let mut available = HashMap::new();
1438
1439        for (transport_type, transport) in &self.transports {
1440            let status = transport.status();
1441            let metrics = if status == TransportStatus::Available {
1442                Some(transport.metrics())
1443            } else {
1444                None
1445            };
1446            statuses.insert(*transport_type, status);
1447            if let Some(mut metrics) = metrics {
1448                if let Some(stats) = self.observations.get(transport_type) {
1449                    stats.apply_to_metrics(*transport_type, &mut metrics);
1450                }
1451                self.apply_device_battery(&mut metrics);
1452                available.insert(*transport_type, metrics);
1453            }
1454        }
1455
1456        (statuses, available)
1457    }
1458
1459    /// Starts all transports.
1460    pub fn start(&mut self) -> Result<()> {
1461        for (transport_type, transport) in &self.transports {
1462            transport.start().map_err(|e| {
1463                Error::Other(format!(
1464                    "Failed to start transport {:?}: {}",
1465                    transport_type, e
1466                ))
1467            })?;
1468        }
1469        Ok(())
1470    }
1471
1472    /// Stops all transports.
1473    pub fn stop(&mut self) -> Result<()> {
1474        for (transport_type, transport) in &self.transports {
1475            transport.stop().map_err(|e| {
1476                Error::Other(format!(
1477                    "Failed to stop transport {:?}: {}",
1478                    transport_type, e
1479                ))
1480            })?;
1481        }
1482        Ok(())
1483    }
1484
1485    /// Gets a shared handle to a specific transport.
1486    pub fn get_transport(&self, transport_type: TransportType) -> Option<Arc<dyn Transport>> {
1487        self.transports.get(&transport_type).cloned()
1488    }
1489
1490    /// Removes a transport from the manager.
1491    ///
1492    /// # Arguments
1493    ///
1494    /// * `transport_type` - Type of transport to remove
1495    pub fn remove_transport(&mut self, transport_type: TransportType) {
1496        self.transports.remove(&transport_type);
1497
1498        // Clear current transport if it was the one removed
1499        if self.current_transport == Some(transport_type) {
1500            self.current_transport = None;
1501        }
1502
1503        self.observations.remove(&transport_type);
1504    }
1505
1506    /// Gets a list of all active transport types.
1507    ///
1508    /// # Returns
1509    ///
1510    /// Returns a vector of transport types that are currently added to the manager.
1511    pub fn get_active_transports(&self) -> Vec<TransportType> {
1512        self.transports.keys().copied().collect()
1513    }
1514
1515    /// Checks if DORS suggests escalating from BLE to WiFi Direct.
1516    pub fn should_escalate_to_wifi(&self) -> bool {
1517        self.selector.should_escalate_to_wifi()
1518    }
1519
1520    /// Records a retry failure for the given transport.
1521    pub fn record_retry_failure(&mut self, transport_type: TransportType) {
1522        self.selector.record_retry_failure(transport_type);
1523    }
1524
1525    /// Resets retry count for the given transport after successful delivery.
1526    pub fn reset_retry_count(&mut self, transport_type: TransportType) {
1527        self.selector.reset_retry_count(transport_type);
1528    }
1529
1530    /// Records a successful end-to-end delivery for the given transport.
1531    pub fn record_delivery_success(
1532        &mut self,
1533        transport_type: TransportType,
1534        latency_ms: u32,
1535        hop_count: u8,
1536    ) {
1537        let stats = self.observations.entry(transport_type).or_default();
1538        stats.record_success(latency_ms, hop_count);
1539    }
1540
1541    /// Records a delivery failure (after exhausting retries) for the given transport.
1542    pub fn record_delivery_failure(&mut self, transport_type: TransportType) {
1543        let stats = self.observations.entry(transport_type).or_default();
1544        stats.record_failure();
1545    }
1546
1547    #[cfg(test)]
1548    pub(crate) fn observed_failure_count(&self, transport_type: TransportType) -> u32 {
1549        self.observations
1550            .get(&transport_type)
1551            .map_or(0, |stats| stats.failure_count)
1552    }
1553
1554    /// Updates the DORS selector configuration at runtime, preserving
1555    /// accumulated state (transport history, retry counts, signal tracking).
1556    pub fn update_selector_config(&mut self, config: DorsConfig) {
1557        self.selector.update_config(config);
1558    }
1559
1560    /// Returns the DORS configuration transport scoring runs against.
1561    pub fn selector_config(&self) -> &DorsConfig {
1562        self.selector.config()
1563    }
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568    use super::*;
1569    use offline_protocol_core::{AppId, UserId};
1570    use offline_protocol_router::DorsConfig;
1571    use offline_protocol_transport::{mock::MockTransport, TransportType};
1572    use std::sync::Mutex;
1573
1574    fn create_test_message() -> Message {
1575        Message::new(
1576            UserId::new("alice").unwrap(),
1577            UserId::new("bob").unwrap(),
1578            AppId::new("test").unwrap(),
1579            "Test message",
1580        )
1581    }
1582
1583    #[test]
1584    fn test_transport_manager_creation() {
1585        let selector = TransportSelector::with_config(DorsConfig::default());
1586        let manager = TransportManager::new(selector);
1587        assert_eq!(manager.transports.len(), 0);
1588        assert!(manager.current_transport().is_none());
1589    }
1590
1591    #[test]
1592    fn test_add_transport() {
1593        let selector = TransportSelector::with_config(DorsConfig::default());
1594        let mut manager = TransportManager::new(selector);
1595
1596        let transport = Box::new(MockTransport::new(TransportType::BLE));
1597        manager.add_transport(TransportType::BLE, transport);
1598
1599        assert_eq!(manager.transports.len(), 1);
1600    }
1601
1602    #[test]
1603    fn test_start_stop_through_shared_transport_handle() {
1604        let selector = TransportSelector::with_config(DorsConfig::default());
1605        let mut manager = TransportManager::new(selector);
1606        manager.add_transport(
1607            TransportType::BLE,
1608            Box::new(MockTransport::new(TransportType::BLE)),
1609        );
1610
1611        // The handle shares the same transport the manager drives: start()
1612        // and stop() go through &self, no per-transport mutex involved.
1613        let handle = manager
1614            .get_transport(TransportType::BLE)
1615            .expect("transport registered");
1616        assert_eq!(handle.status(), TransportStatus::Unavailable);
1617
1618        manager.start().unwrap();
1619        assert_eq!(handle.status(), TransportStatus::Available);
1620
1621        manager.stop().unwrap();
1622        assert_eq!(handle.status(), TransportStatus::Disconnected);
1623    }
1624
1625    #[test]
1626    fn test_send_message() {
1627        let selector = TransportSelector::with_config(DorsConfig::default());
1628        let mut manager = TransportManager::new(selector);
1629
1630        let transport = MockTransport::new(TransportType::BLE);
1631        transport.start().unwrap();
1632        manager.add_transport(TransportType::BLE, Box::new(transport));
1633
1634        let message = create_test_message();
1635        let result = manager.send(&message);
1636        assert!(result.is_ok());
1637    }
1638
1639    #[test]
1640    fn send_stamps_binary_only_for_capable_peer() {
1641        let selector = TransportSelector::with_config(DorsConfig::default());
1642        let mut manager = TransportManager::new(selector);
1643        let transport = MockTransport::new(TransportType::BLE);
1644        transport.start().unwrap();
1645        manager.add_transport(TransportType::BLE, Box::new(transport));
1646
1647        // Unknown peer -> JSON (the default).
1648        manager.send(&create_test_message()).unwrap();
1649        // Peer recorded as binary-capable -> the send is stamped binary.
1650        manager.mark_peer_binary_wire("bob", true);
1651        manager.send(&create_test_message()).unwrap();
1652
1653        let mock = manager
1654            .transports
1655            .get(&TransportType::BLE)
1656            .unwrap()
1657            .as_any()
1658            .downcast_ref::<MockTransport>()
1659            .unwrap();
1660        let sent = mock.sent_messages();
1661        assert_eq!(sent.len(), 2);
1662        assert_eq!(sent[0].wire_codec(), WireCodec::Json);
1663        assert_eq!(sent[1].wire_codec(), WireCodec::BinaryV1);
1664    }
1665
1666    #[test]
1667    fn send_reverts_to_json_after_peer_downgrades() {
1668        let selector = TransportSelector::with_config(DorsConfig::default());
1669        let mut manager = TransportManager::new(selector);
1670        let transport = MockTransport::new(TransportType::BLE);
1671        transport.start().unwrap();
1672        manager.add_transport(TransportType::BLE, Box::new(transport));
1673
1674        // Learn the peer is binary-capable -> the next send is stamped binary.
1675        manager.mark_peer_binary_wire("bob", true);
1676        assert!(manager.peer_supports_binary_wire("bob"));
1677        manager.send(&create_test_message()).unwrap();
1678
1679        // A later key package that no longer advertises support downgrades the
1680        // peer (e.g. after a session reset, or the codec being turned off on
1681        // their side); subsequent sends must fall back to JSON, not keep
1682        // emitting binary a legacy build could not decode.
1683        manager.mark_peer_binary_wire("bob", false);
1684        assert!(!manager.peer_supports_binary_wire("bob"));
1685        manager.send(&create_test_message()).unwrap();
1686
1687        let mock = manager
1688            .transports
1689            .get(&TransportType::BLE)
1690            .unwrap()
1691            .as_any()
1692            .downcast_ref::<MockTransport>()
1693            .unwrap();
1694        let sent = mock.sent_messages();
1695        assert_eq!(sent.len(), 2);
1696        assert_eq!(sent[0].wire_codec(), WireCodec::BinaryV1);
1697        assert_eq!(sent[1].wire_codec(), WireCodec::Json);
1698    }
1699
1700    #[test]
1701    fn test_dors_events_emitted_when_callback_set() {
1702        let selector = TransportSelector::with_config(DorsConfig::default());
1703        let mut manager = TransportManager::new(selector);
1704
1705        let transport = MockTransport::new(TransportType::BLE);
1706        transport.start().unwrap();
1707        manager.add_transport(TransportType::BLE, Box::new(transport));
1708
1709        let events: std::sync::Arc<Mutex<Vec<Event>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1710        let events_clone = events.clone();
1711        manager.set_dors_event_callback(Some(Arc::new(move |e| {
1712            events_clone.lock().unwrap().push(e);
1713        })));
1714
1715        let message = create_test_message();
1716        let _ = manager.send(&message);
1717
1718        let captured = events.lock().unwrap();
1719        assert!(
1720            captured
1721                .iter()
1722                .any(|e| matches!(e, crate::events::Event::DorsScoreUpdated { .. })),
1723            "expected dors_score_updated"
1724        );
1725        assert!(
1726            captured
1727                .iter()
1728                .any(|e| { matches!(e, crate::events::Event::DorsTransportSelected { .. }) }),
1729            "expected dors_transport_selected"
1730        );
1731    }
1732
1733    #[test]
1734    fn test_receive_message() {
1735        let selector = TransportSelector::with_config(DorsConfig::default());
1736        let mut manager = TransportManager::new(selector);
1737
1738        let transport = MockTransport::new(TransportType::BLE);
1739        transport.start().unwrap();
1740        let message = create_test_message();
1741        transport.queue_message(message.clone());
1742
1743        manager.add_transport(TransportType::BLE, Box::new(transport));
1744
1745        let received = manager.receive().unwrap();
1746        assert!(matches!(received, Some((TransportType::BLE, _))));
1747    }
1748
1749    #[test]
1750    fn test_record_delivery_success_enriches_metrics() {
1751        let selector = TransportSelector::with_config(DorsConfig::default());
1752        let mut manager = TransportManager::new(selector);
1753
1754        let transport = MockTransport::new(TransportType::BLE);
1755        transport.start().unwrap();
1756        manager.add_transport(TransportType::BLE, Box::new(transport));
1757
1758        manager.record_delivery_success(TransportType::BLE, 150, 2);
1759
1760        let metrics = manager.get_available_transports();
1761        let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1762
1763        assert_eq!(ble_metrics.success_count, 1);
1764        assert_eq!(ble_metrics.failure_count, 0);
1765        assert!(ble_metrics.delivery_ratio.expect("delivery ratio") > 0.99);
1766        assert_eq!(ble_metrics.average_hop_count, Some(2.0));
1767        assert_eq!(ble_metrics.latency_ms, Some(150));
1768    }
1769
1770    #[test]
1771    fn test_record_delivery_failure_enriches_metrics() {
1772        let selector = TransportSelector::with_config(DorsConfig::default());
1773        let mut manager = TransportManager::new(selector);
1774
1775        let transport = MockTransport::new(TransportType::BLE);
1776        transport.start().unwrap();
1777        manager.add_transport(TransportType::BLE, Box::new(transport));
1778
1779        manager.record_delivery_failure(TransportType::BLE);
1780        let metrics = manager.get_available_transports();
1781        let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1782
1783        assert_eq!(ble_metrics.success_count, 0);
1784        assert_eq!(ble_metrics.failure_count, 1);
1785        assert!(ble_metrics.drop_rate.expect("drop ratio") > 0.99);
1786    }
1787
1788    /// A mesh transport with no send confirmation loop takes the engine's
1789    /// delivery evidence even when it carries a stray count of its own: the
1790    /// value-based rule discarded every acknowledgement after one (#557).
1791    #[test]
1792    fn engine_observations_win_over_a_mesh_transports_own_counts() {
1793        let selector = TransportSelector::with_config(DorsConfig::default());
1794        let mut manager = TransportManager::new(selector);
1795
1796        let transport = MockTransport::new(TransportType::BLE);
1797        transport.start().unwrap();
1798        transport.set_metrics(TransportMetrics {
1799            failure_count: 1,
1800            delivery_ratio: Some(0.0),
1801            drop_rate: Some(1.0),
1802            ..Default::default()
1803        });
1804        manager.add_transport(TransportType::BLE, Box::new(transport));
1805
1806        manager.record_delivery_success(TransportType::BLE, 150, 1);
1807
1808        let metrics = manager.get_available_transports();
1809        let ble = metrics.get(&TransportType::BLE).expect("metrics");
1810        assert_eq!((ble.success_count, ble.failure_count), (1, 0));
1811        assert_eq!(ble.delivery_ratio, Some(1.0));
1812        assert_eq!(ble.drop_rate, Some(0.0));
1813    }
1814
1815    /// A transport with a confirmation loop owns its counts: the platform's
1816    /// per-send outcomes are better evidence than the engine's
1817    /// acknowledgements and must not be overwritten.
1818    #[test]
1819    fn a_confirmation_loop_transport_keeps_its_own_counts() {
1820        let selector = TransportSelector::with_config(DorsConfig::default());
1821        let mut manager = TransportManager::new(selector);
1822
1823        let transport = MockTransport::new(TransportType::Internet);
1824        transport.start().unwrap();
1825        transport.set_metrics(TransportMetrics {
1826            success_count: 5,
1827            failure_count: 1,
1828            delivery_ratio: Some(5.0 / 6.0),
1829            drop_rate: Some(1.0 / 6.0),
1830            ..Default::default()
1831        });
1832        manager.add_transport(TransportType::Internet, Box::new(transport));
1833
1834        manager.record_delivery_failure(TransportType::Internet);
1835
1836        let metrics = manager.get_available_transports();
1837        let internet = metrics.get(&TransportType::Internet).expect("metrics");
1838        assert_eq!((internet.success_count, internet.failure_count), (5, 1));
1839        assert_eq!(internet.delivery_ratio, Some(5.0 / 6.0));
1840    }
1841
1842    /// End to end over a real BLE transport: an inbound partial message
1843    /// evicted at capacity must not read as a 0% delivery ratio, so a
1844    /// handful of sends afterwards neither trips the low-success-rate
1845    /// escalation nor hides the engine's acknowledgements (#557).
1846    #[test]
1847    fn a_dropped_inbound_fragment_does_not_escalate_ble() {
1848        use offline_protocol_transport::constants::BLE_MAX_FRAGMENT_ASSEMBLIES;
1849        use offline_protocol_transport::{BleTransport, PeerDevice};
1850
1851        let selector = TransportSelector::with_config(DorsConfig::default());
1852        let mut manager = TransportManager::new(selector);
1853
1854        let ble = BleTransport::new("alice-device");
1855        ble.start().unwrap();
1856        ble.on_peer_discovered(PeerDevice {
1857            device_id: "bob".to_string(),
1858            address: "AA:BB:CC:DD:EE:FF".to_string(),
1859            rssi: -60,
1860            last_seen: offline_protocol_core::time::SystemTime::now(),
1861            connected: true,
1862        });
1863        manager.add_transport(TransportType::BLE, Box::new(ble));
1864        let wifi = MockTransport::new(TransportType::WiFiDirect);
1865        wifi.start().unwrap();
1866        manager.add_transport(TransportType::WiFiDirect, Box::new(wifi));
1867
1868        // Fill the reassembly cache with first fragments of multi-fragment
1869        // messages, then one more: the cache evicts a partial assembly.
1870        let ble = manager.get_transport(TransportType::BLE).expect("ble");
1871        let ble_ref = ble.as_any().downcast_ref::<BleTransport>().expect("ble");
1872        for _ in 0..=BLE_MAX_FRAGMENT_ASSEMBLIES {
1873            let large = Message::new(
1874                UserId::new("bob").unwrap(),
1875                UserId::new("alice").unwrap(),
1876                AppId::new("test").unwrap(),
1877                "x".repeat(2_000),
1878            );
1879            let fragments = ble_ref.fragment_message(&large).expect("fragments");
1880            assert!(fragments.len() > 1, "the message must span fragments");
1881            ble.on_fragment_received_from(fragments[0].clone(), "bob".to_string())
1882                .expect("fragment accepted");
1883        }
1884        drop(ble);
1885
1886        // More sends than the low-success-rate sample floor.
1887        for _ in 0..8 {
1888            manager.send(&create_test_message()).expect("send");
1889        }
1890
1891        assert!(
1892            !manager.should_escalate_to_wifi(),
1893            "a receive-side reassembly drop must not read as a failing BLE link"
1894        );
1895        let metrics = manager.get_available_transports();
1896        assert_ne!(
1897            metrics[&TransportType::BLE].delivery_ratio,
1898            Some(0.0),
1899            "BLE must not report a 0% delivery ratio with no failed delivery"
1900        );
1901
1902        manager.record_delivery_success(TransportType::BLE, 120, 1);
1903        let metrics = manager.get_available_transports();
1904        assert_eq!(metrics[&TransportType::BLE].delivery_ratio, Some(1.0));
1905    }
1906
1907    /// A carrier that refuses the recipient with nothing left to fall back to
1908    /// ends the send as `PeerNotReachable`, not as a carrier that broke
1909    /// mid-send. The deferral token and every classifier read this variant.
1910    #[test]
1911    fn a_sole_carrier_refusing_the_recipient_ends_as_peer_not_reachable() {
1912        let selector = TransportSelector::with_config(DorsConfig::default());
1913        let mut manager = TransportManager::new(selector);
1914
1915        let transport = MockTransport::new(TransportType::BLE);
1916        transport.start().unwrap();
1917        transport.set_reject_unknown_recipients(true);
1918        manager.add_transport(TransportType::BLE, Box::new(transport));
1919
1920        let err = manager.send(&create_test_message()).unwrap_err();
1921        assert!(
1922            matches!(err, Error::Transport(TransportError::PeerNotReachable(_))),
1923            "got {err:?}"
1924        );
1925    }
1926
1927    /// Two transports, neither reporting a battery level of its own — the
1928    /// ordinary case, since no platform transport publishes one. Both must
1929    /// come back carrying the host reading, because every downstream consumer
1930    /// (DORS energy scoring, the relay role, the forwarding floor) resolves
1931    /// battery through this map and would otherwise take its unknown branch.
1932    #[test]
1933    fn test_device_battery_fills_transports_that_report_none() {
1934        let selector = TransportSelector::with_config(DorsConfig::default());
1935        let mut manager = TransportManager::new(selector);
1936
1937        for transport_type in [TransportType::BLE, TransportType::WiFiDirect] {
1938            let transport = MockTransport::new(transport_type);
1939            transport.start().unwrap();
1940            manager.add_transport(transport_type, Box::new(transport));
1941        }
1942
1943        assert_eq!(manager.device_battery(), (None, false));
1944        manager.set_device_battery(42, true);
1945        assert_eq!(manager.device_battery(), (Some(42), true));
1946
1947        let metrics = manager.get_available_transports();
1948        for transport_type in [TransportType::BLE, TransportType::WiFiDirect] {
1949            let m = metrics.get(&transport_type).expect("metrics");
1950            assert_eq!(m.battery_level, Some(42));
1951            assert!(m.is_charging);
1952        }
1953
1954        // The one-pass snapshot builds the same map and must agree with it —
1955        // the telemetry aggregator and the relay-role evaluation read this one.
1956        let (_statuses, available) = manager.snapshot_status_and_available();
1957        let m = available.get(&TransportType::BLE).expect("metrics");
1958        assert_eq!(m.battery_level, Some(42));
1959        assert!(m.is_charging);
1960    }
1961
1962    /// A transport that publishes its own battery reading keeps it, and keeps
1963    /// its own charging state with it: mixing a level from one source with a
1964    /// charging flag from another is what produces the spurious
1965    /// charging-state flips `device_battery_from_available` documents.
1966    #[test]
1967    fn test_transport_reported_battery_wins_over_device_feed() {
1968        let selector = TransportSelector::with_config(DorsConfig::default());
1969        let mut manager = TransportManager::new(selector);
1970
1971        let transport = MockTransport::new(TransportType::BLE);
1972        transport.start().unwrap();
1973        transport.set_metrics(TransportMetrics {
1974            battery_level: Some(7),
1975            is_charging: false,
1976            ..Default::default()
1977        });
1978        manager.add_transport(TransportType::BLE, Box::new(transport));
1979
1980        manager.set_device_battery(90, true);
1981
1982        let metrics = manager.get_available_transports();
1983        let m = metrics.get(&TransportType::BLE).expect("metrics");
1984        assert_eq!(m.battery_level, Some(7));
1985        assert!(!m.is_charging);
1986    }
1987
1988    #[test]
1989    fn test_device_battery_is_clamped() {
1990        let selector = TransportSelector::with_config(DorsConfig::default());
1991        let mut manager = TransportManager::new(selector);
1992        manager.set_device_battery(240, false);
1993        assert_eq!(manager.device_battery(), (Some(100), false));
1994    }
1995}