Skip to main content

peat_ffi/
lib.rs

1//! Peat FFI - Foreign Function Interface for Kotlin/Swift
2//!
3//! This crate provides UniFFI bindings to expose Peat functionality
4//! to Kotlin (Android) and Swift (iOS) consumer applications.
5//!
6//! ## Features
7//!
8//! - **CoT Encoding**: Convert track data to Cursor-on-Target XML
9//! - **Sync** (optional): P2P document sync via AutomergeIroh backend
10//!
11//! Uses proc-macro only UniFFI approach (no UDL file).
12//!
13//! ## Android JNI Support
14//!
15//! This crate also provides direct JNI bindings that bypass JNA's symbol lookup
16//! issues on Android. The JNI functions are exported with standard naming
17//! (Java_package_Class_method) and can be called directly via Android's NDK.
18
19// Allow pre-existing warnings in FFI code - will clean up incrementally
20#![allow(unused_variables)]
21#![allow(unused_mut)]
22#![allow(dead_code)]
23#![allow(clippy::incompatible_msrv)]
24#![allow(clippy::unnecessary_cast)]
25#![allow(clippy::single_match)]
26#![allow(clippy::items_after_test_module)]
27
28use std::collections::HashMap;
29use std::sync::Arc;
30
31// JNI support for Android
32use jni::objects::{GlobalRef, JByteArray, JClass, JString, JValue};
33use jni::sys::{jboolean, jint, jstring, JavaVM, JNI_VERSION_1_6};
34use jni::JNIEnv;
35use std::os::raw::c_void;
36use std::sync::{LazyLock, Mutex};
37
38// Global JavaVM reference for JNI callbacks from any thread
39static JAVA_VM: LazyLock<Mutex<Option<jni::JavaVM>>> = LazyLock::new(|| Mutex::new(None));
40
41// Global reference to PeerEventManager class
42static PEER_EVENT_MANAGER_CLASS: LazyLock<Mutex<Option<GlobalRef>>> =
43    LazyLock::new(|| Mutex::new(None));
44
45// Global reference to the currently-registered DocumentChangeListener instance.
46// Only one subscription is supported at a time (mirrors UniFFI's
47// PeatNode::subscribe constraint). Held as a GlobalRef so it survives across
48// JNI thread attaches.
49#[cfg(feature = "sync")]
50static DOCUMENT_CHANGE_LISTENER: LazyLock<Mutex<Option<GlobalRef>>> =
51    LazyLock::new(|| Mutex::new(None));
52
53// Flag controlling the lifetime of the document-change subscription task.
54// Set to true by subscribeDocumentChangesJni, false by
55// unsubscribeDocumentChangesJni. The spawned tokio task polls this on each recv
56// to know whether to exit.
57#[cfg(feature = "sync")]
58static DOCUMENT_SUBSCRIPTION_ACTIVE: LazyLock<std::sync::atomic::AtomicBool> =
59    LazyLock::new(|| std::sync::atomic::AtomicBool::new(false));
60
61// peat#885 fault-injection flag, test-only. When armed via
62// `forceStoreErrorForTestingJni`, the next `getDocumentJni` call
63// short-circuits to the Err branch (throws RuntimeException) without
64// touching the underlying store. Self-clears on consumption — one
65// trigger per arm. Process-wide rather than per-handle because tests
66// typically run sequentially on a single instrumented runner; if
67// concurrent multi-handle fault injection is ever needed, promote
68// to a `HashMap<handle, AtomicBool>` keyed by node handle.
69//
70// Always present in non-test builds (the function name's "ForTesting"
71// suffix is the API marker; calling it from production code does no
72// harm beyond setting a flag that a non-test code path never reads
73// because production never calls forceStoreErrorForTestingJni).
74#[cfg(feature = "sync")]
75static FORCE_STORE_ERROR_FOR_TESTING: std::sync::atomic::AtomicBool =
76    std::sync::atomic::AtomicBool::new(false);
77
78// ADR-059 Slice 1.b.2: outbound BLE frame callback. The Kotlin listener
79// receives `onFrame(transportId, collection, bytes)` for every encoded
80// document the BLE translator produces in `TransportManager`'s fan-out.
81// Replaceable: a second subscribe swaps the GlobalRef without re-registering
82// the underlying translator/sink. Cleared on unsubscribe.
83#[cfg(all(feature = "sync", feature = "bluetooth"))]
84static OUTBOUND_FRAME_LISTENER: LazyLock<Mutex<Option<GlobalRef>>> =
85    LazyLock::new(|| Mutex::new(None));
86
87// FanoutHandle held alive across the subscription lifetime. Drop cancels
88// the observer tasks; `unsubscribeOutboundFramesJni` takes the value and
89// drops it explicitly. Wrapped in a feature-gated alias because the type
90// only exists when peat-mesh is in scope.
91#[cfg(all(feature = "sync", feature = "bluetooth"))]
92static OUTBOUND_FRAME_FANOUT: LazyLock<Mutex<Option<peat_mesh::transport::FanoutHandle>>> =
93    LazyLock::new(|| Mutex::new(None));
94
95// Global Peat node handle that survives APK replacement
96// This allows Kotlin code to recover the node connection after plugin hot-swap
97#[cfg(feature = "sync")]
98static GLOBAL_NODE_HANDLE: LazyLock<Mutex<i64>> = LazyLock::new(|| Mutex::new(0));
99
100/// Store an **owning** reference to `node` in [`GLOBAL_NODE_HANDLE`], dropping
101/// any previously-stored owning reference.
102///
103/// Invariant: the slot holds either `0` or a pointer produced here by
104/// `Arc::into_raw(Arc::clone(..))` — i.e. always an owning reference,
105/// independent of how the *originating* handle is released (UniFFI/Dart GC for
106/// `create_node`, or `freeNodeJni` for the JNI create paths). This is what
107/// makes `getGlobalNodeHandleJni` safe for the BLE bridge: the node can't be
108/// freed out from under a consumer while the global still references it.
109/// [`clearGlobalNodeHandleJni`] releases this reference. Centralising the
110/// write here keeps every create path consistent so the clear path can always
111/// safely `Arc::from_raw` + drop without risking a double-free or UAF.
112#[cfg(feature = "sync")]
113fn set_global_node_handle(node: &Arc<PeatNode>) {
114    store_owning_node_in_slot(&GLOBAL_NODE_HANDLE, node);
115}
116
117/// Store an owning `Arc<PeatNode>` pointer in `slot`, dropping any previously
118/// stored owning pointer. Factored out of [`set_global_node_handle`] /
119/// [`clearGlobalNodeHandleJni`] so the store/clear ownership semantics can be
120/// unit-tested against a *local* slot (the real `GLOBAL_NODE_HANDLE` is
121/// process-global shared state that parallel tests would race on).
122#[cfg(feature = "sync")]
123fn store_owning_node_in_slot(slot: &Mutex<i64>, node: &Arc<PeatNode>) {
124    if let Ok(mut g) = slot.lock() {
125        let prev = std::mem::replace(&mut *g, Arc::into_raw(Arc::clone(node)) as i64);
126        if prev != 0 {
127            // SAFETY: `prev` was produced by `Arc::into_raw(Arc::clone(..))`
128            // in a prior call to this helper; reclaim it to drop that ref.
129            unsafe { drop(Arc::from_raw(prev as *const PeatNode)) };
130        }
131    }
132}
133
134/// Zero `slot`, dropping the owning `Arc<PeatNode>` pointer it held (if any).
135/// Backs [`clearGlobalNodeHandleJni`].
136#[cfg(feature = "sync")]
137fn clear_owning_node_slot(slot: &Mutex<i64>) {
138    if let Ok(mut g) = slot.lock() {
139        let prev = std::mem::replace(&mut *g, 0);
140        if prev != 0 {
141            // SAFETY: the slot only ever holds `0` or an owning pointer from
142            // `store_owning_node_in_slot`; reclaim + drop to release the ref.
143            unsafe { drop(Arc::from_raw(prev as *const PeatNode)) };
144        }
145    }
146}
147
148// Global BLE transport reference for Android JNI access
149// Kotlin signals BLE state (started/stopped, peer discovery) into this
150// transport which makes TransportManager aware of BLE availability for PACE
151// routing.
152#[cfg(all(feature = "bluetooth", target_os = "android"))]
153static ANDROID_BLE_TRANSPORT: LazyLock<
154    Mutex<Option<Arc<PeatBleTransport<peat_btle::platform::android::AndroidAdapter>>>>,
155> = LazyLock::new(|| Mutex::new(None));
156
157use peat_protocol::cot::{
158    CotEncoder, Position as CotPosition, TrackUpdate, Velocity as CotVelocity,
159};
160
161#[cfg(feature = "sync")]
162use peat_protocol::network::{IrohTransport, PeerInfo as PeatPeerInfo, TransportPeerEvent};
163#[cfg(feature = "sync")]
164use peat_protocol::storage::{AutomergeBackend, AutomergeStore, StorageBackend, SyncCapable};
165#[cfg(feature = "sync")]
166use peat_protocol::sync::automerge::AutomergeIrohBackend;
167#[cfg(feature = "sync")]
168use peat_protocol::sync::{BackendConfig, DataSyncBackend, TransportConfig};
169// Blob transfer via peat-mesh NetworkedIrohBlobStore (ADR-060).
170// Parallel endpoint model — blob store runs its own iroh Router/Endpoint
171// separate from PeatNode.iroh_transport's sync endpoint.
172use peat_mesh::storage::automerge_store::{
173    ChangeOrigin as _PeatMeshChangeOrigin, DocChange as _PeatMeshDocChange,
174};
175#[cfg(feature = "sync")]
176use peat_mesh::storage::{
177    BlobMetadata, BlobStore, BlobStoreExt, BlobToken, NetworkedIrohBlobStore,
178};
179#[cfg(feature = "sync")]
180use peat_mesh::IrohConfig as PeatMeshIrohConfig;
181#[cfg(all(feature = "sync", feature = "bluetooth"))]
182use peat_protocol::transport::btle::PeatBleTransport;
183#[cfg(feature = "sync")]
184use peat_protocol::transport::{
185    CollectionRouteTable, IrohMeshTransport, Transport, TransportCapabilities, TransportInstance,
186    TransportManager, TransportManagerConfig, TransportPolicy, TransportType,
187};
188#[cfg(feature = "sync")]
189use std::net::SocketAddr;
190#[cfg(feature = "sync")]
191use std::path::PathBuf;
192#[cfg(feature = "sync")]
193use std::sync::atomic::{AtomicBool, Ordering};
194
195// Setup UniFFI scaffolding
196uniffi::setup_scaffolding!();
197
198// FFIBuffer wrappers for Dart FFI bindings
199pub mod dart_ffi;
200
201// Shared water-supply Counter — a self-contained Automerge CRDT doc carried
202// over BLE (CRDT-over-Automerge-over-BLE). See docs/crdt-counter-over-ble.md.
203#[cfg(feature = "sync")]
204mod water_counter;
205
206// Generic CRDT key-value documents (nodes/commands/cells/mission) — same
207// Automerge-over-BLE pattern as the counter, for record collections.
208#[cfg(feature = "sync")]
209mod crdt_kv;
210
211/// Get the Peat library version
212#[uniffi::export]
213pub fn peat_version() -> String {
214    env!("CARGO_PKG_VERSION").to_string()
215}
216
217/// Geographic position for FFI
218#[derive(Debug, Clone, uniffi::Record)]
219pub struct Position {
220    /// Latitude in degrees (WGS84)
221    pub lat: f64,
222    /// Longitude in degrees (WGS84)
223    pub lon: f64,
224    /// Height Above Ellipsoid in meters (optional)
225    pub hae: Option<f64>,
226}
227
228/// Velocity vector for FFI
229#[derive(Debug, Clone, uniffi::Record)]
230pub struct Velocity {
231    /// Bearing in degrees (0 = North, clockwise)
232    pub bearing: f64,
233    /// Speed in meters per second
234    pub speed_mps: f64,
235}
236
237/// Track data for CoT encoding
238#[derive(Debug, Clone, uniffi::Record)]
239pub struct TrackData {
240    /// Unique track identifier
241    pub track_id: String,
242    /// Source node ID
243    pub source_node: String,
244    /// Geographic position
245    pub position: Position,
246    /// Optional velocity
247    pub velocity: Option<Velocity>,
248    /// MIL-STD-2525 classification (e.g., "a-f-G-U-C")
249    pub classification: String,
250    /// Detection confidence (0.0 - 1.0)
251    pub confidence: f64,
252    /// Optional cell ID (for squad-level tracks)
253    pub cell_id: Option<String>,
254    /// Optional formation ID
255    pub formation_id: Option<String>,
256}
257
258/// FFI Error type
259#[derive(Debug, thiserror::Error, uniffi::Error)]
260pub enum PeatError {
261    #[error("Encoding error: {msg}")]
262    EncodingError { msg: String },
263    #[error("Invalid input: {msg}")]
264    InvalidInput { msg: String },
265    #[error("Storage error: {msg}")]
266    StorageError { msg: String },
267    #[error("Connection error: {msg}")]
268    ConnectionError { msg: String },
269    #[error("Sync error: {msg}")]
270    SyncError { msg: String },
271}
272
273/// Encode a track to CoT XML string
274#[uniffi::export]
275pub fn encode_track_to_cot(track: TrackData) -> Result<String, PeatError> {
276    // Validate input
277    if track.track_id.is_empty() {
278        return Err(PeatError::InvalidInput {
279            msg: "track_id cannot be empty".to_string(),
280        });
281    }
282
283    // Convert FFI types to internal types
284    let position = CotPosition {
285        lat: track.position.lat,
286        lon: track.position.lon,
287        cep_m: None,
288        hae: track.position.hae,
289    };
290
291    let velocity = track.velocity.map(|v| CotVelocity {
292        bearing: v.bearing,
293        speed_mps: v.speed_mps,
294    });
295
296    let track_update = TrackUpdate {
297        track_id: track.track_id,
298        source_node: track.source_node,
299        source_model: "peat-ffi".to_string(),
300        model_version: peat_version(),
301        cell_id: track.cell_id,
302        formation_id: track.formation_id,
303        timestamp: chrono::Utc::now(),
304        position,
305        velocity,
306        classification: track.classification,
307        confidence: track.confidence,
308        attributes: HashMap::new(),
309    };
310
311    // Encode to CoT
312    let encoder = CotEncoder::new();
313    let event = encoder
314        .track_update_to_event(&track_update)
315        .map_err(|e| PeatError::EncodingError { msg: e.to_string() })?;
316
317    event
318        .to_xml()
319        .map_err(|e| PeatError::EncodingError { msg: e.to_string() })
320}
321
322/// Create a position from coordinates
323#[uniffi::export]
324pub fn create_position(lat: f64, lon: f64, hae: Option<f64>) -> Position {
325    Position { lat, lon, hae }
326}
327
328/// Create a velocity from bearing and speed
329#[uniffi::export]
330pub fn create_velocity(bearing: f64, speed_mps: f64) -> Velocity {
331    Velocity { bearing, speed_mps }
332}
333
334// =============================================================================
335// PeatNode - P2P Sync Support (requires "sync" feature)
336// =============================================================================
337
338/// Transport configuration for BLE and other transports (ADR-039, #556)
339///
340/// Controls which transports are enabled and their settings.
341/// Used by `NodeConfig` to configure multi-transport support.
342#[cfg(feature = "sync")]
343#[derive(Debug, Clone, Default, uniffi::Record)]
344pub struct TransportConfigFFI {
345    /// Enable Bluetooth LE transport (requires "bluetooth" feature)
346    /// When enabled, BLE mesh networking is available alongside Iroh/QUIC
347    pub enable_ble: bool,
348    /// BLE mesh ID (optional, defaults to app_id if not specified)
349    /// Used to identify the BLE mesh network for peer discovery
350    pub ble_mesh_id: Option<String>,
351    /// BLE power profile: "aggressive", "balanced", or "low_power"
352    /// - aggressive: Maximum range/speed, higher battery usage
353    /// - balanced: Default, moderate power usage
354    /// - low_power: Minimal battery impact, reduced range/speed
355    pub ble_power_profile: Option<String>,
356    /// Transport preference order (optional)
357    /// List of transport names in order of preference, e.g., ["iroh", "ble",
358    /// "lora"] Used by TransportManager's PACE policy for transport
359    /// selection
360    pub transport_preference: Option<Vec<String>>,
361    /// Per-collection transport routing (optional)
362    /// JSON-encoded CollectionRouteTable for explicit collection->transport
363    /// bindings. Collections not listed fall through to PACE/legacy
364    /// scoring.
365    pub collection_routes_json: Option<String>,
366    /// Enable iroh's n0 hosted public relay pool + DNS discovery at runtime
367    /// (peat-flutter relay toggle). Default `false` keeps the local-only,
368    /// no-phone-home posture (`presets::Empty`). When `true`, the iroh
369    /// endpoint is built with `presets::N0`, routing traffic through n0's
370    /// PUBLIC relay infrastructure (`*.iroh.network`) so internet-connected
371    /// devices can sync without a shared LAN. Opt-in only.
372    ///
373    /// MUST remain the last field: the hand-maintained Dart FFI codec in
374    /// peat-flutter (`peat_ffi.dart`) reads/writes record fields in
375    /// declaration order, and the field was appended there too.
376    pub enable_n0_relay: bool,
377}
378
379/// Configuration for creating a PeatNode
380#[cfg(feature = "sync")]
381#[derive(Debug, Clone, uniffi::Record)]
382pub struct NodeConfig {
383    /// Application/Formation ID (used for peer discovery and authentication)
384    /// This identifies which "formation" or "swarm" this node belongs to.
385    pub app_id: String,
386    /// Shared secret key (base64-encoded 32 bytes) for peer authentication
387    /// Only peers with matching app_id AND shared_key can connect.
388    /// Generate with: `openssl rand -base64 32`
389    pub shared_key: String,
390    /// Bind address for P2P connections (e.g., "0.0.0.0:0" for auto-assign)
391    pub bind_address: Option<String>,
392    /// Storage path for Automerge documents
393    pub storage_path: String,
394    /// Transport configuration (optional, defaults to Iroh-only)
395    /// Use this to enable BLE and configure multi-transport behavior
396    pub transport: Option<TransportConfigFFI>,
397}
398
399/// Information about a peer node for connection
400#[cfg(feature = "sync")]
401#[derive(Debug, Clone, uniffi::Record)]
402pub struct PeerInfo {
403    /// Human-readable peer name
404    pub name: String,
405    /// Hex-encoded node ID (Iroh endpoint ID)
406    pub node_id: String,
407    /// List of addresses (e.g., "127.0.0.1:19001")
408    pub addresses: Vec<String>,
409    /// Optional relay URL
410    pub relay_url: Option<String>,
411}
412
413/// Sync statistics
414#[cfg(feature = "sync")]
415#[derive(Debug, Clone, uniffi::Record)]
416pub struct SyncStats {
417    /// Whether sync is currently active
418    pub sync_active: bool,
419    /// Number of connected peers
420    pub connected_peers: u32,
421    /// Total bytes sent
422    pub bytes_sent: u64,
423    /// Total bytes received
424    pub bytes_received: u64,
425}
426
427// =============================================================================
428// ADR-032 §Amendment A — Per-Peer Transport State (UniFFI surface)
429// =============================================================================
430//
431// Mirror types over `peat_mesh::transport::LinkState` family. The
432// peat-mesh types aren't UniFFI-decorated (they live in the transport
433// layer, not the binding layer), so we re-shape them into peat-ffi
434// `Record`s/`Enum`s with `From<peat_mesh::...>` conversions. Kotlin
435// plugin consumers render directly off these.
436//
437// Per ADR-032 §Amendment A's host-rendering rule, peat-ffi is the
438// *single source of truth* for transport-state queries in the UI; the
439// plugin MUST NOT reach into peat-btle's UniFFI directly for this
440// purpose. The unified loop walks `TransportManager`, calls
441// `peer_link_state` on each registered transport, and overlays
442// `transport_id` from the registered id (interface overlay is a
443// follow-up — `TransportManager` doesn't yet expose a public
444// instance-metadata accessor).
445
446/// Per-peer transport state across all registered transports.
447///
448/// Returned by [`PeatNode::peer_transport_state`] and contained in the
449/// list returned by [`PeatNode::all_peer_transport_states`]. An empty
450/// `links` vec is a valid state and means "this peer is not currently
451/// reachable via any registered transport" — visualization should
452/// render the peer with no transport badges, not as an error.
453#[cfg(feature = "sync")]
454#[derive(Debug, Clone, uniffi::Record)]
455pub struct PeerTransportState {
456    /// Hex-encoded peer node identifier (matches the form produced by
457    /// `PeatNode::node_id` and `PeatNode::connected_peers`).
458    pub peer_id: String,
459    /// Links for each transport that currently has a record of this
460    /// peer. Order is implementation-defined (usually
461    /// `TransportManager`'s registration order). An empty list is
462    /// valid — see struct docs.
463    pub links: Vec<TransportLink>,
464}
465
466/// One transport's link state for a peer (FFI mirror of
467/// `peat_mesh::transport::LinkState`).
468#[cfg(feature = "sync")]
469#[derive(Debug, Clone, uniffi::Record)]
470pub struct TransportLink {
471    /// Identifies the registered transport instance, e.g. `"ble-hci0"`,
472    /// `"iroh-wlan0"`. Per ADR-032 §Amendment A, peat-ffi overlays this
473    /// from the `TransportManager`-registered id at synthesis time.
474    pub transport_id: String,
475    /// Transport family, lowercase string for cross-language
476    /// portability (`"ble"` / `"iroh"` / `"lora"` / `"satellite"` / …).
477    pub transport_type: String,
478    /// Physical interface name where applicable (`eth0`, `wlan0`,
479    /// `p2p-wlan0`). `None` for transports that don't expose a NIC
480    /// concept (e.g. BLE, LoRa).
481    pub interface: Option<String>,
482    /// Bucketed quality. Each transport defines its own thresholds.
483    pub quality: TransportLinkQuality,
484    /// Round-trip-time estimate in milliseconds, where the transport
485    /// can measure or estimate it.
486    pub rtt_ms: Option<u32>,
487    /// Received signal strength in dBm, populated by transports that
488    /// expose it (BLE, LoRa, tactical radio). `None` for IP transports.
489    pub rssi_dbm: Option<i8>,
490    /// Path classification for IP-style transports with a relay
491    /// concept (iroh's `PathInfo::is_relay()`). `None` where the
492    /// concept doesn't apply (BLE).
493    pub path_kind: Option<TransportPathKind>,
494}
495
496/// Bucketed link quality for UI tier indicators.
497#[cfg(feature = "sync")]
498#[derive(Debug, Clone, Copy, uniffi::Enum)]
499pub enum TransportLinkQuality {
500    Excellent,
501    Good,
502    Fair,
503    Weak,
504    Unknown,
505}
506
507/// Connection path classification.
508///
509/// `Mixed` (multi-path concurrent) was considered during ADR-032
510/// §Amendment A and intentionally deferred until a real emitter exists.
511#[cfg(feature = "sync")]
512#[derive(Debug, Clone, Copy, uniffi::Enum)]
513pub enum TransportPathKind {
514    Direct,
515    Relay,
516}
517
518#[cfg(feature = "sync")]
519impl From<peat_mesh::transport::LinkQuality> for TransportLinkQuality {
520    fn from(q: peat_mesh::transport::LinkQuality) -> Self {
521        match q {
522            peat_mesh::transport::LinkQuality::Excellent => TransportLinkQuality::Excellent,
523            peat_mesh::transport::LinkQuality::Good => TransportLinkQuality::Good,
524            peat_mesh::transport::LinkQuality::Fair => TransportLinkQuality::Fair,
525            peat_mesh::transport::LinkQuality::Weak => TransportLinkQuality::Weak,
526            peat_mesh::transport::LinkQuality::Unknown => TransportLinkQuality::Unknown,
527        }
528    }
529}
530
531#[cfg(feature = "sync")]
532impl From<peat_mesh::transport::PathKind> for TransportPathKind {
533    fn from(p: peat_mesh::transport::PathKind) -> Self {
534        match p {
535            peat_mesh::transport::PathKind::Direct => TransportPathKind::Direct,
536            peat_mesh::transport::PathKind::Relay => TransportPathKind::Relay,
537        }
538    }
539}
540
541#[cfg(feature = "sync")]
542impl From<peat_mesh::transport::LinkState> for TransportLink {
543    fn from(s: peat_mesh::transport::LinkState) -> Self {
544        // `transport_type` to lowercase string — the ADR's enum names
545        // (BluetoothLE, Quic, etc.) are descriptive but don't match the
546        // string form callers tend to use ("ble", "iroh"). Map
547        // explicitly so a future enum-variant addition is a compile-
548        // time prompt to extend this map rather than silently emitting
549        // a Debug-formatted string.
550        let transport_type = match s.transport_type {
551            peat_mesh::transport::TransportType::BluetoothLE => "ble".to_string(),
552            peat_mesh::transport::TransportType::Quic => "iroh".to_string(),
553            peat_mesh::transport::TransportType::LoRa => "lora".to_string(),
554            peat_mesh::transport::TransportType::WifiDirect => "wifi-direct".to_string(),
555            peat_mesh::transport::TransportType::TacticalRadio => "tactical-radio".to_string(),
556            peat_mesh::transport::TransportType::Satellite => "satellite".to_string(),
557            peat_mesh::transport::TransportType::BluetoothClassic => {
558                "bluetooth-classic".to_string()
559            }
560            peat_mesh::transport::TransportType::Custom(n) => format!("custom-{n}"),
561        };
562        TransportLink {
563            transport_id: s.transport_id,
564            transport_type,
565            interface: s.interface,
566            quality: s.quality.into(),
567            rtt_ms: s.rtt_ms,
568            rssi_dbm: s.rssi_dbm,
569            path_kind: s.path_kind.map(Into::into),
570        }
571    }
572}
573
574/// Type of document change event
575#[cfg(feature = "sync")]
576#[derive(Debug, Clone, uniffi::Enum)]
577pub enum ChangeType {
578    /// Document was created or updated
579    Upsert,
580    /// Document was deleted
581    Delete,
582}
583
584/// Document change event for subscriptions
585#[cfg(feature = "sync")]
586#[derive(Debug, Clone, uniffi::Record)]
587pub struct DocumentChange {
588    /// Collection name
589    pub collection: String,
590    /// Document ID
591    pub doc_id: String,
592    /// Type of change
593    pub change_type: ChangeType,
594}
595
596/// Encoded BLE outbound frame produced by the `BleTranslator` fan-out.
597///
598/// Received by calling [`PeatNode::poll_outbound_frames`] on the host side.
599/// The host is responsible for the final transport-specific framing (GATT
600/// write, encryption envelope) before putting `bytes` on the radio.
601#[cfg(all(feature = "sync", feature = "bluetooth"))]
602#[derive(Debug, Clone, uniffi::Record)]
603pub struct OutboundFrame {
604    /// Transport identifier — `"ble"` for typed 0xB6 frames, `"ble-lite"`
605    /// for universal-Document (peat-lite) frames.
606    pub transport_id: String,
607    /// Collection the document belongs to (e.g. `"tracks"`, `"platforms"`).
608    pub collection: String,
609    /// postcard-encoded typed BLE struct ready for the radio.
610    pub bytes: Vec<u8>,
611}
612
613/// Callback interface for document change notifications
614///
615/// Implement this interface in Kotlin/Swift to receive document updates.
616#[cfg(feature = "sync")]
617#[uniffi::export(callback_interface)]
618pub trait DocumentCallback: Send + Sync {
619    /// Called when a document changes
620    fn on_change(&self, change: DocumentChange);
621
622    /// Called when an error occurs in the subscription
623    fn on_error(&self, message: String);
624}
625
626/// Outbound transport-frame callback for non-Android platforms (iOS via
627/// UniFFI). Mirrors the Android `OutboundFrameListener` JNI surface
628/// (`subscribeOutboundFramesJni`); the trait method receives the same
629/// `(transport_id, collection, bytes)` triple per encoded document.
630///
631/// On Android the JNI path is used directly because UniFFI 0.28's Kotlin
632/// backend wraps callback interfaces in `com.sun.jna.Callback`, which
633/// fails under Android plugin-host classloader isolation. Implementations
634/// on non-Android platforms should expect any-thread invocation from the
635/// `peat-mesh` runtime.
636///
637/// The `register_outbound_frame_callback` method on [`PeatNode`] that
638/// would consume this trait is deferred to a follow-up: the
639/// `Drop`-vs-async `unregister_translator` interaction needs an
640/// `Arc<TransportManager>` refactor of `PeatNode` to be done cleanly
641/// (current `TransportManager` field is owned, not Arc-wrapped, so a
642/// subscription handle has no clean way to drive teardown on drop).
643/// The trait declaration here serves as documentation of the iOS-side
644/// shape so the follow-up can land without an FFI break.
645#[cfg(all(feature = "sync", feature = "bluetooth"))]
646#[uniffi::export(callback_interface)]
647pub trait OutboundFrameCallback: Send + Sync {
648    fn on_frame(&self, transport_id: String, collection: String, bytes: Vec<u8>);
649}
650
651/// Handle for an active document subscription
652///
653/// Drop this handle to unsubscribe from document changes.
654#[cfg(feature = "sync")]
655#[derive(uniffi::Object)]
656pub struct SubscriptionHandle {
657    active: Arc<AtomicBool>,
658    /// Queued changes for polling consumers (populated by `subscribe_poll`).
659    pending: Arc<std::sync::Mutex<std::collections::VecDeque<DocumentChange>>>,
660}
661
662#[cfg(feature = "sync")]
663impl SubscriptionHandle {
664    fn new(active: Arc<AtomicBool>) -> Self {
665        Self {
666            active,
667            pending: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
668        }
669    }
670
671    fn new_with_queue(
672        active: Arc<AtomicBool>,
673        pending: Arc<std::sync::Mutex<std::collections::VecDeque<DocumentChange>>>,
674    ) -> Self {
675        Self { active, pending }
676    }
677}
678
679#[cfg(feature = "sync")]
680#[uniffi::export]
681impl SubscriptionHandle {
682    /// Check if the subscription is still active
683    pub fn is_active(&self) -> bool {
684        self.active.load(Ordering::SeqCst)
685    }
686
687    /// Cancel the subscription
688    pub fn cancel(&self) {
689        self.active.store(false, Ordering::SeqCst);
690    }
691
692    /// Drain all pending document changes. Non-blocking.
693    ///
694    /// Only populated when the subscription was opened via
695    /// [`PeatNode::subscribe_poll`]. Always returns an empty Vec for
696    /// subscriptions opened via [`PeatNode::subscribe`] (callback path).
697    pub fn poll_changes(&self) -> Vec<DocumentChange> {
698        self.pending
699            .lock()
700            .map(|mut q| q.drain(..).collect())
701            .unwrap_or_default()
702    }
703}
704
705#[cfg(feature = "sync")]
706impl Drop for SubscriptionHandle {
707    fn drop(&mut self) {
708        self.active.store(false, Ordering::SeqCst);
709    }
710}
711
712/// A Peat network node with P2P sync capabilities
713///
714/// Wraps AutomergeIrohBackend for authenticated document sync.
715/// Requires matching app_id and shared_key for peer connections.
716#[cfg(feature = "sync")]
717#[derive(uniffi::Object)]
718pub struct PeatNode {
719    /// The sync backend with FormationKey authentication
720    sync_backend: Arc<AutomergeIrohBackend>,
721    /// Storage backend for document operations (shared with sync_backend)
722    /// Note: This is the SAME backend instance used by sync_backend to ensure
723    /// sync coordinator state is shared. Do NOT create a separate backend.
724    storage_backend: Arc<AutomergeBackend>,
725    /// Generic application-level mesh document layer wrapping `sync_backend`.
726    /// Composed alongside the existing typed surface (nodes, cells,
727    /// tracks, …) so callers can reach generic publish/get/query/observe
728    /// without going through type-specific JNI methods. Foundation step 3 of
729    /// the peat-mesh-completion / peat-btle-reduction work — see
730    /// `PEAT-MESH-COMPLETION-0.9.0.md`.
731    #[cfg(feature = "sync")]
732    node: Arc<peat_mesh::Node>,
733    /// peat-protocol's [`BleTranslator`] (ADR-041) used by the `ingest*Jni`
734    /// family of methods. Translates typed BLE structs to Automerge
735    /// documents; the result is published into [`Self::node`] with
736    /// `Some("ble")` origin so ADR-059's same-node echo suppression keeps
737    /// the doc from being re-encoded back out to BLE. The earlier
738    /// `BleGateway` wrapper composing translator + node was removed in
739    /// Slice 1.b.2.2 — composition happens inline in the JNI helpers
740    /// because peat-ffi owns both halves anyway, so the wrapper added no
741    /// boundary worth defending.
742    ///
743    /// [`BleTranslator`]: peat_protocol::sync::ble_translation::BleTranslator
744    #[cfg(all(feature = "sync", feature = "bluetooth"))]
745    ble_translator: Arc<peat_protocol::sync::ble_translation::BleTranslator>,
746    /// Transport manager for multi-transport coordination (ADR-032)
747    /// Enables PACE policy-based transport selection and future BLE integration
748    transport_manager: TransportManager,
749    /// Direct reference to Iroh transport for backward-compatible methods
750    /// (peer_count, connected_peers, etc.)
751    iroh_transport: Arc<IrohTransport>,
752    /// Store reference for subscriptions
753    store: Arc<AutomergeStore>,
754    #[allow(dead_code)] // Kept for potential future use (e.g., storage cleanup)
755    storage_path: PathBuf,
756    /// Tokio runtime for async operations
757    runtime: Arc<tokio::runtime::Runtime>,
758    /// Flag to stop cleanup task on drop (used by background task)
759    #[allow(dead_code)]
760    cleanup_running: Arc<AtomicBool>,
761    /// Optional blob store running on a parallel iroh endpoint (ADR-060).
762    /// None when blob transfer is disabled — this is the common case for
763    /// sim nodes that don't need to serve or fetch binary payloads.
764    /// Constructed via PeatNode::enable_blob_transfer() after node creation.
765    #[cfg(feature = "sync")]
766    blob_store: std::sync::RwLock<Option<Arc<NetworkedIrohBlobStore>>>,
767    /// Queue of outbound BLE frames produced by the `BleTranslator` fan-out.
768    /// Populated by `QueueOutboundSink::send_outbound`; drained by
769    /// `poll_outbound_frames`. None when the `bluetooth` feature is off.
770    #[cfg(all(feature = "sync", feature = "bluetooth"))]
771    outbound_queue: Arc<std::sync::Mutex<std::collections::VecDeque<OutboundFrame>>>,
772    /// `FanoutHandle` for the active outbound subscription, if any.
773    /// Held alive between `start_outbound_frames` and `stop_outbound_frames`.
774    #[cfg(all(feature = "sync", feature = "bluetooth"))]
775    outbound_fanout: std::sync::Mutex<Option<peat_mesh::transport::FanoutHandle>>,
776    /// Dedup set for BLE multi-hop relay: frame-hash -> last-relayed instant.
777    ///
778    /// peat-mesh's fan-out re-fans an ingested frame to OTHER transports but
779    /// SUPPRESSES same-transport (BLE->BLE) re-emit to avoid a broadcast loop
780    /// (ADR-059 echo-suppression). That suppression also blocks legitimate
781    /// multi-hop relay in an all-BLE topology (A -> B -> C): B applies A's
782    /// frame but never forwards it to C, so C can stay permanently stale.
783    /// We re-emit each freshly-ingested frame onto the BLE outbound queue
784    /// so B relays it to C. The dedup (bounded, TTL-swept) throttles
785    /// identical re-advertises so a relayed frame isn't re-broadcast in a
786    /// loop — a NEW value (different bytes) always relays immediately;
787    /// redundant re-adverts within the TTL are dropped. See
788    /// peat#978-adjacent relay gap.
789    #[cfg(all(feature = "sync", feature = "bluetooth"))]
790    relay_seen: std::sync::Mutex<std::collections::HashMap<u64, std::time::Instant>>,
791    /// Shared water-supply Counter (CRDT-over-Automerge-over-BLE).
792    /// Self-contained Automerge doc; its save() bytes ride the BLE frame
793    /// bus and merge natively.
794    #[cfg(feature = "sync")]
795    water_counter: water_counter::WaterCounter,
796    /// Generic CRDT KV documents (nodes/commands/cells/mission), Automerge over
797    /// the same crdt frame as the counter — mesh-wide convergence, no
798    /// lite-bridge.
799    #[cfg(feature = "sync")]
800    crdt_kv: crdt_kv::CrdtKvDocs,
801}
802
803#[cfg(feature = "sync")]
804#[uniffi::export]
805impl PeatNode {
806    // ── Shared water-supply Counter (CRDT-over-Automerge-over-BLE) ──────────
807    // The doc's save() bytes are carried over the BLE frame bus; merge is
808    // commutative/idempotent, so the caller can broadcast/relay freely.
809
810    // The Automerge doc bytes cross the FFI as a HEX string (the well-trodden
811    // String marshalling path; the doc is tiny so 2x size is irrelevant). The
812    // caller broadcasts the hex over the BLE bridge and feeds inbound hex to
813    // `crdt_counter_merge`.
814
815    /// Current merged value of the shared water-supply Counter.
816    pub fn crdt_counter_value(&self) -> i64 {
817        self.water_counter.value()
818    }
819
820    /// Apply `delta` liters to the shared Counter; returns the doc's save()
821    /// bytes (hex) for the caller to broadcast to peers.
822    pub fn crdt_counter_increment(&self, delta: i64) -> String {
823        hex::encode(self.water_counter.increment(delta))
824    }
825
826    /// Merge an inbound peer doc (hex of its save() bytes); returns the new
827    /// value. Safe with duplicate / stale / relayed / out-of-order input.
828    pub fn crdt_counter_merge(&self, hex_doc: String) -> i64 {
829        match hex::decode(hex_doc.trim()) {
830            Ok(bytes) => self.water_counter.merge(&bytes),
831            Err(_) => self.water_counter.value(),
832        }
833    }
834
835    /// Current save() bytes (hex), for periodic re-broadcast (catch-up).
836    pub fn crdt_counter_snapshot(&self) -> String {
837        hex::encode(self.water_counter.snapshot())
838    }
839
840    // ── Generic CRDT KV documents (nodes/commands/cells/mission) ────────────
841    // Records are key -> JSON-string in a per-collection Automerge doc; merge is
842    // set-union across keys (LWW per key). Same crdt-frame transport as the
843    // counter; doc bytes cross the FFI as hex.
844
845    /// Upsert `key = value_json` in `collection`; returns the doc's save()
846    /// bytes (hex) to broadcast.
847    pub fn crdt_kv_put(&self, collection: String, key: String, value_json: String) -> String {
848        hex::encode(self.crdt_kv.put(&collection, &key, &value_json))
849    }
850
851    /// All records in `collection` as a JSON object `{key: value}`.
852    pub fn crdt_kv_all(&self, collection: String) -> String {
853        self.crdt_kv.all_json(&collection)
854    }
855
856    /// Merge an inbound peer doc (hex) into `collection`.
857    pub fn crdt_kv_merge(&self, collection: String, hex_doc: String) {
858        if let Ok(bytes) = hex::decode(hex_doc.trim()) {
859            self.crdt_kv.merge(&collection, &bytes);
860        }
861    }
862
863    /// Current save() bytes (hex) of `collection`, for periodic re-broadcast.
864    pub fn crdt_kv_snapshot(&self, collection: String) -> String {
865        hex::encode(self.crdt_kv.snapshot(&collection))
866    }
867
868    /// Get this node's unique identifier (hex-encoded)
869    pub fn node_id(&self) -> String {
870        hex::encode(self.iroh_transport.endpoint_id().as_bytes())
871    }
872
873    /// Get this node's endpoint address for peer connections
874    pub fn endpoint_addr(&self) -> String {
875        format!("{:?}", self.iroh_transport.endpoint_addr())
876    }
877
878    /// Get the number of connected peers
879    pub fn peer_count(&self) -> u32 {
880        self.iroh_transport.peer_count() as u32
881    }
882
883    /// Get list of connected peer IDs
884    pub fn connected_peers(&self) -> Vec<String> {
885        self.iroh_transport
886            .connected_peers()
887            .iter()
888            .map(|id| hex::encode(id.as_bytes()))
889            .collect()
890    }
891
892    /// Return this node's iroh-endpoint first IP listening address
893    /// as an `"ip:port"` string, or `None` if no socket has been
894    /// bound yet.
895    ///
896    /// Intended for two-instance instrumented tests where two nodes
897    /// in the same process need to dial each other on loopback —
898    /// neither has the other's address from discovery, so the test
899    /// harness fetches it here and passes it to `connectPeerJni` on
900    /// the dialing side. peat-mesh#138 M4.
901    pub fn endpoint_socket_addr(&self) -> Option<String> {
902        self.iroh_transport.bound_socket_addr_string()
903    }
904
905    /// Start sync operations
906    ///
907    /// The authenticated accept loop (with formation handshake) is already
908    /// running from sync_backend.initialize() in create_node(). This method
909    /// starts the sync coordination layer: event-based and polling-based
910    /// sync handlers.
911    pub fn start_sync(&self) -> Result<(), PeatError> {
912        #[cfg(target_os = "android")]
913        android_log("start_sync: called");
914
915        // IMPORTANT: Use runtime.enter() to ensure tokio::spawn() inside start_sync()
916        // can find the runtime context. block_on() alone doesn't guarantee this on
917        // all platforms (especially Android where the JNI thread may not have proper
918        // thread-local storage for the Tokio runtime handle).
919        let _guard = self.runtime.enter();
920
921        #[cfg(target_os = "android")]
922        android_log("start_sync: runtime entered");
923
924        // Must run inside Tokio runtime because start_sync() calls tokio::spawn()
925        let result = self.runtime.block_on(async {
926            #[cfg(target_os = "android")]
927            android_log("start_sync: inside block_on");
928
929            // CRITICAL: Call start_sync() on the ACTUAL storage_backend instance,
930            // NOT on sync_backend.sync_engine() which returns a CLONED instance
931            // that doesn't have the transport event subscriptions set up!
932            //
933            // Note: The authenticated accept loop (with formation handshake and
934            // Connected event emission) is already running — it was started by
935            // sync_backend.initialize() in create_node(). The storage_backend's
936            // start_sync() will see the accept loop as already running and skip
937            // starting the plain (unauthenticated) accept loop.
938            self.storage_backend
939                .start_sync()
940                .map_err(|e| PeatError::SyncError { msg: e.to_string() })
941        });
942
943        #[cfg(target_os = "android")]
944        match &result {
945            Ok(_) => android_log("start_sync: SUCCESS - sync handlers spawned"),
946            Err(e) => android_log(&format!("start_sync: FAILED - {}", e)),
947        }
948
949        result
950    }
951
952    /// Stop sync operations
953    pub fn stop_sync(&self) -> Result<(), PeatError> {
954        // Must run inside Tokio runtime for consistency with start_sync()
955        self.runtime.block_on(async {
956            self.storage_backend
957                .stop_sync()
958                .map_err(|e| PeatError::SyncError { msg: e.to_string() })
959        })
960    }
961
962    /// Get sync statistics
963    pub fn sync_stats(&self) -> Result<SyncStats, PeatError> {
964        let stats = self
965            .storage_backend
966            .sync_stats()
967            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
968
969        Ok(SyncStats {
970            sync_active: stats.peer_count > 0, // Infer from peer count
971            connected_peers: self.iroh_transport.peer_count() as u32,
972            bytes_sent: stats.bytes_sent,
973            bytes_received: stats.bytes_received,
974        })
975    }
976
977    /// ADR-032 §Amendment A — unified per-peer transport state.
978    ///
979    /// Walks `TransportManager` for the given peer, calls
980    /// `peer_link_state` on each registered transport that can reach
981    /// it, and overlays the registered `TransportInstance.id` onto the
982    /// returned `LinkState.transport_id` (per the host-rendering rule:
983    /// the producer doesn't know its own registered id, the consumer
984    /// fills it). Returns `Ok(PeerTransportState { peer_id, links: vec![] })`
985    /// for peers no transport reports — "absence is a valid state."
986    ///
987    /// Hex-encoded `peer_id` matches the form `connected_peers()`
988    /// returns. Invalid hex is propagated as-is to peat-mesh's
989    /// `NodeId::new`, which is also a `String` wrapper — invalid input
990    /// surfaces as an empty `links` vec rather than an error, matching
991    /// the absence contract.
992    pub fn peer_transport_state(&self, peer_id: String) -> Result<PeerTransportState, PeatError> {
993        let mesh_peer = peat_mesh::NodeId::new(peer_id.clone());
994        let links = self
995            .transport_manager
996            .available_instances_for_peer(&mesh_peer)
997            .into_iter()
998            .filter_map(|transport_id| {
999                let transport = self.transport_manager.get_instance(&transport_id)?;
1000                let mut state = transport.peer_link_state(&mesh_peer)?;
1001                // Host-rendering rule: overlay the registered id onto
1002                // the producer's placeholder. See
1003                // `peat_mesh::transport::btle::BLE_TRANSPORT_ID_PLACEHOLDER`.
1004                state.transport_id = transport_id;
1005                Some(TransportLink::from(state))
1006            })
1007            .collect();
1008        Ok(PeerTransportState { peer_id, links })
1009    }
1010
1011    /// ADR-032 §Amendment A — transport state for the peer set this
1012    /// `peat-ffi` instance currently enumerates from iroh.
1013    ///
1014    /// Designed for the plugin's periodic poll (~2 s) — the
1015    /// implementation walks transport state in a single pass without
1016    /// per-peer recursion.
1017    ///
1018    /// **Coverage caveat (Slice-4.d-interim — not the final SSOT
1019    /// shape).** This method enumerates peers exclusively from
1020    /// `self.iroh_transport.connected_peers()`. BLE-only peers
1021    /// (peers reachable via peat-btle but not currently visible to
1022    /// iroh) are **not** included. Plugin authors must continue to
1023    /// merge BLE-only peers from peat-btle's UniFFI surface
1024    /// directly until the single-source-of-truth migration
1025    /// completes. The Amendment A SSOT promise — "peat-ffi is the
1026    /// single source of truth, the plugin MUST NOT reach into
1027    /// peat-btle's UniFFI directly" — is the destination, not the
1028    /// current implementation; this method's coverage is a strict
1029    /// subset of that destination. Treat the cross-FFI peat-btle
1030    /// reach as a documented interim, not an idiom to standardize on.
1031    /// Tracked under defenseunicorns/peat#828.
1032    pub fn all_peer_transport_states(&self) -> Result<Vec<PeerTransportState>, PeatError> {
1033        // Collect a deduped peer set across registered transports.
1034        // peat-mesh's TransportManager doesn't expose a single
1035        // "all known peers" iterator, so we union over registered
1036        // instance peers via `iroh_transport.connected_peers()` for
1037        // the iroh side (the only transport peat-ffi currently
1038        // surfaces directly). BLE-side peers come through the
1039        // bluetooth feature's transport registration; their
1040        // connected_peers are surfaced through the same walk on
1041        // peer_transport_state once the caller knows their id from
1042        // the BLE-side UniFFI lookup. For now this method covers
1043        // peers visible to iroh; the plugin merges BLE-only peers
1044        // from its peat-btle UniFFI consumer separately while the
1045        // single-source-of-truth migration completes.
1046        let mut peer_ids: Vec<String> = self
1047            .iroh_transport
1048            .connected_peers()
1049            .iter()
1050            .map(|id| hex::encode(id.as_bytes()))
1051            .collect();
1052        peer_ids.sort();
1053        peer_ids.dedup();
1054
1055        let mut out = Vec::with_capacity(peer_ids.len());
1056        for peer_id in peer_ids {
1057            out.push(self.peer_transport_state(peer_id)?);
1058        }
1059        Ok(out)
1060    }
1061
1062    /// Request a full document sync with all connected peers.
1063    /// This pushes all local documents to each peer and pulls any documents
1064    /// they have. Useful for ensuring newly created documents propagate
1065    /// after the initial connection.
1066    pub fn request_sync(&self) -> Result<(), PeatError> {
1067        if let Some(coordinator) = self.storage_backend.sync_coordinator() {
1068            let peers = self.iroh_transport.connected_peers();
1069            let peer_count = peers.len();
1070            // Logcat-visible signal of every request_sync invocation:
1071            // peer count + each push's success/failure. peat-protocol's
1072            // internal `tracing::info!` doesn't reach logcat because no
1073            // tracing-subscriber is installed on Android, so the only
1074            // way to observe whether `sync_all_documents_with_peer`
1075            // actually ran is to surface it here at the FFI boundary
1076            // where `android_log` works.
1077            #[cfg(target_os = "android")]
1078            android_log(&format!(
1079                "request_sync: starting with {} connected peer(s)",
1080                peer_count
1081            ));
1082            let coord = Arc::clone(coordinator);
1083            self.runtime.block_on(async {
1084                for peer_id in peers {
1085                    match coord.sync_all_documents_with_peer(peer_id).await {
1086                        Ok(()) => {
1087                            #[cfg(target_os = "android")]
1088                            {
1089                                let peer_hex = hex::encode(peer_id.as_bytes());
1090                                android_log(&format!(
1091                                    "request_sync: pushed to peer {}",
1092                                    &peer_hex[..16]
1093                                ));
1094                            }
1095                        }
1096                        Err(_e) => {
1097                            #[cfg(target_os = "android")]
1098                            {
1099                                let peer_hex = hex::encode(peer_id.as_bytes());
1100                                android_log(&format!(
1101                                    "request_sync: FAILED for peer {}: {}",
1102                                    &peer_hex[..16],
1103                                    _e
1104                                ));
1105                            }
1106                        }
1107                    }
1108                }
1109            });
1110            #[cfg(target_os = "android")]
1111            android_log(&format!(
1112                "request_sync: complete ({} peer(s) attempted)",
1113                peer_count
1114            ));
1115        }
1116        Ok(())
1117    }
1118
1119    /// Connect to a peer node with formation handshake
1120    ///
1121    /// Establishes a QUIC connection, performs formation-key authentication,
1122    /// and emits a Connected event to trigger immediate sync handler spawning.
1123    pub fn connect_peer(&self, peer: PeerInfo) -> Result<(), PeatError> {
1124        let peat_peer = PeatPeerInfo {
1125            name: peer.name,
1126            node_id: peer.node_id,
1127            addresses: peer.addresses,
1128            relay_url: peer.relay_url,
1129        };
1130
1131        let _guard = self.runtime.enter();
1132
1133        self.runtime.block_on(async {
1134            let conn_opt = self
1135                .iroh_transport
1136                .connect_peer(&peat_peer)
1137                .await
1138                .map_err(|e| PeatError::ConnectionError { msg: e.to_string() })?;
1139
1140            // If we got a new connection, perform formation handshake and emit Connected
1141            if let Some(conn) = conn_opt {
1142                let peer_id = conn.remote_id();
1143
1144                if let Some(formation_key) = self.sync_backend.formation_key() {
1145                    use peat_protocol::network::perform_initiator_handshake;
1146                    match perform_initiator_handshake(&conn, &formation_key).await {
1147                        Ok(()) => {
1148                            // Emit Connected to trigger immediate sync handler spawning
1149                            self.iroh_transport.emit_peer_connected(peer_id);
1150
1151                            // Explicitly trigger document sync with the new peer.
1152                            // The event-based sync handler spawner should handle this,
1153                            // but we also trigger sync directly to ensure documents flow.
1154                            if let Some(coordinator) = self.storage_backend.sync_coordinator() {
1155                                let coord = Arc::clone(coordinator);
1156                                let sync_peer = peer_id;
1157                                tokio::spawn(async move {
1158                                    // Brief delay for connection to stabilize
1159                                    tokio::time::sleep(tokio::time::Duration::from_millis(500))
1160                                        .await;
1161                                    #[cfg(target_os = "android")]
1162                                    android_log(&format!(
1163                                        "Triggering sync_all_documents_with_peer for {:?}",
1164                                        sync_peer
1165                                    ));
1166                                    match coord.sync_all_documents_with_peer(sync_peer).await {
1167                                        Ok(()) => {
1168                                            #[cfg(target_os = "android")]
1169                                            android_log("sync_all_documents_with_peer: SUCCESS");
1170                                        }
1171                                        Err(e) => {
1172                                            #[cfg(target_os = "android")]
1173                                            android_log(&format!(
1174                                                "sync_all_documents_with_peer: FAILED - {}",
1175                                                e
1176                                            ));
1177                                        }
1178                                    }
1179                                });
1180                            }
1181                        }
1182                        Err(e) => {
1183                            conn.close(1u32.into(), b"authentication failed");
1184                            self.iroh_transport.disconnect(&peer_id).ok();
1185                            return Err(PeatError::ConnectionError {
1186                                msg: format!("Formation handshake failed: {}", e),
1187                            });
1188                        }
1189                    }
1190                } else {
1191                    // No formation key — emit Connected without handshake (backward compat)
1192                    self.iroh_transport.emit_peer_connected(peer_id);
1193                }
1194            }
1195            // If None, accept path is handling the connection
1196
1197            Ok(())
1198        })
1199    }
1200
1201    /// Disconnect from a peer by node ID
1202    ///
1203    /// Note: Currently disconnects matching peer from internal connection map.
1204    pub fn disconnect_peer(&self, node_id: &str) -> Result<(), PeatError> {
1205        // Find the matching endpoint ID from connected peers
1206        let connected = self.iroh_transport.connected_peers();
1207        for endpoint_id in connected {
1208            if hex::encode(endpoint_id.as_bytes()) == node_id {
1209                return self
1210                    .iroh_transport
1211                    .disconnect(&endpoint_id)
1212                    .map_err(|e| PeatError::ConnectionError { msg: e.to_string() });
1213            }
1214        }
1215
1216        Err(PeatError::ConnectionError {
1217            msg: format!("Peer {} not found in connected peers", node_id),
1218        })
1219    }
1220
1221    /// Store a JSON document in a collection
1222    pub fn put_document(
1223        &self,
1224        collection: &str,
1225        doc_id: &str,
1226        json_data: &str,
1227    ) -> Result<(), PeatError> {
1228        // Parse JSON to validate it
1229        let _: serde_json::Value =
1230            serde_json::from_str(json_data).map_err(|e| PeatError::InvalidInput {
1231                msg: format!("Invalid JSON: {}", e),
1232            })?;
1233
1234        self.runtime.block_on(async {
1235            let backend = &self.storage_backend;
1236            let coll = backend.collection(collection);
1237
1238            coll.upsert(doc_id, json_data.as_bytes().to_vec())
1239                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
1240        })
1241    }
1242
1243    /// Retrieve a document from the **raw-bytes store** as JSON.
1244    ///
1245    /// # Storage path
1246    ///
1247    /// This reads from `storage_backend.collection()` — the raw
1248    /// key-value store. It will NOT see documents that were:
1249    ///
1250    /// - Published via `publishDocumentJni` (which goes through
1251    ///   `peat_mesh::Node::publish`, the document layer)
1252    /// - Received from a peer via Automerge sync (which writes into the
1253    ///   document layer's CRDT, not the raw store)
1254    ///
1255    /// The JNI counterpart `getDocumentJni` deliberately uses
1256    /// `peat_mesh::Node::get()` instead so it round-trips with
1257    /// `publishDocumentJni`. If you're writing a new JNI method
1258    /// that reads documents published or synced via the document
1259    /// layer, follow `getDocumentJni`'s pattern, not this method's.
1260    pub fn get_document(
1261        &self,
1262        collection: &str,
1263        doc_id: &str,
1264    ) -> Result<Option<String>, PeatError> {
1265        self.runtime.block_on(async {
1266            let backend = &self.storage_backend;
1267            let coll = backend.collection(collection);
1268
1269            match coll.get(doc_id) {
1270                Ok(Some(bytes)) => {
1271                    let json = String::from_utf8(bytes).map_err(|e| PeatError::StorageError {
1272                        msg: format!("Invalid UTF-8: {}", e),
1273                    })?;
1274                    Ok(Some(json))
1275                }
1276                Ok(None) => Ok(None),
1277                Err(e) => Err(PeatError::StorageError { msg: e.to_string() }),
1278            }
1279        })
1280    }
1281
1282    /// Delete a document from a collection
1283    pub fn delete_document(&self, collection: &str, doc_id: &str) -> Result<(), PeatError> {
1284        self.runtime.block_on(async {
1285            let backend = &self.storage_backend;
1286            let coll = backend.collection(collection);
1287
1288            coll.delete(doc_id)
1289                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
1290        })
1291    }
1292
1293    /// List all document IDs in a collection
1294    pub fn list_documents(&self, collection: &str) -> Result<Vec<String>, PeatError> {
1295        self.runtime.block_on(async {
1296            let backend = &self.storage_backend;
1297            let coll = backend.collection(collection);
1298
1299            let docs = coll
1300                .scan()
1301                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
1302
1303            Ok(docs.into_iter().map(|(id, _)| id).collect())
1304        })
1305    }
1306
1307    /// Manually trigger sync for a specific document
1308    pub fn sync_document(&self, collection: &str, doc_id: &str) -> Result<(), PeatError> {
1309        let doc_key = format!("{}:{}", collection, doc_id);
1310
1311        self.runtime.block_on(async {
1312            let backend = &self.storage_backend;
1313
1314            backend
1315                .sync_document(&doc_key)
1316                .await
1317                .map_err(|e| PeatError::SyncError { msg: e.to_string() })
1318        })
1319    }
1320
1321    /// Subscribe to document changes
1322    ///
1323    /// Returns a SubscriptionHandle that must be kept alive to receive
1324    /// callbacks. When the handle is dropped or cancel() is called, the
1325    /// subscription stops.
1326    ///
1327    /// The callback will receive DocumentChange events for all documents.
1328    /// Filter by collection in your callback implementation if needed.
1329    ///
1330    /// Note: Only one subscription per node is supported. Calling subscribe
1331    /// again will fail if a subscription is already active.
1332    pub fn subscribe(
1333        &self,
1334        callback: Box<dyn DocumentCallback>,
1335    ) -> Result<Arc<SubscriptionHandle>, PeatError> {
1336        // Subscribe to ALL changes (local + peer-synced). Same origin-based dedup
1337        // as subscribe_poll: Remote events only fire the first time a doc_key is seen.
1338        let change_rx = self.store.subscribe_to_changes_with_origin();
1339
1340        // Create active flag for the subscription
1341        let active = Arc::new(AtomicBool::new(true));
1342        let active_clone = Arc::clone(&active);
1343        // Spawn a task to listen for changes and call the callback.
1344        // Dedup is handled at the Dart layer via content hashing — emit all
1345        // events here so cross-device updates are never silently dropped.
1346        let callback = Arc::new(callback);
1347        self.runtime.spawn(async move {
1348            let mut rx = change_rx;
1349
1350            while active_clone.load(Ordering::SeqCst) {
1351                tokio::select! {
1352                    result = rx.recv() => {
1353                        match result {
1354                            Ok(doc_change) => {
1355                                let doc_key = doc_change.key;
1356                                // Parse the document key (format: "collection:doc_id")
1357                                let change = if let Some((collection, doc_id)) = doc_key.split_once(':') {
1358                                    DocumentChange {
1359                                        collection: collection.to_string(),
1360                                        doc_id: doc_id.to_string(),
1361                                        change_type: ChangeType::Upsert,
1362                                    }
1363                                } else {
1364                                    DocumentChange {
1365                                        collection: "default".to_string(),
1366                                        doc_id: doc_key,
1367                                        change_type: ChangeType::Upsert,
1368                                    }
1369                                };
1370
1371                                callback.on_change(change);
1372                            }
1373                            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1374                                // Some messages were skipped due to slow receiver
1375                                callback.on_error(format!("Lagged {} messages", n));
1376                            }
1377                            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1378                                // Channel closed
1379                                callback.on_error("Document change channel closed".to_string());
1380                                break;
1381                            }
1382                        }
1383                    }
1384                    _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
1385                        // Periodic check if we should stop
1386                        if !active_clone.load(Ordering::SeqCst) {
1387                            break;
1388                        }
1389                    }
1390                }
1391            }
1392        });
1393
1394        Ok(Arc::new(SubscriptionHandle::new(active)))
1395    }
1396
1397    /// Subscribe to document changes using a poll-based model.
1398    ///
1399    /// Returns a [`SubscriptionHandle`] whose
1400    /// [`SubscriptionHandle::poll_changes`] method drains buffered
1401    /// [`DocumentChange`] events. Callers drive delivery by periodically
1402    /// calling `poll_changes` (e.g. from a Dart isolate loop or
1403    /// `Timer.periodic`) — no foreign callback interface is required.
1404    ///
1405    /// Drop or call [`SubscriptionHandle::cancel`] on the handle to stop.
1406    ///
1407    /// # Broadcast lag
1408    ///
1409    /// The underlying channel has a bounded capacity. If `poll_changes` is not
1410    /// called frequently enough relative to the document-change rate, the
1411    /// broadcast channel will lag and silently drop events — `poll_changes`
1412    /// returns a partial set with no indication that events were missed.
1413    /// Callers should treat a long gap between `poll_changes` calls (e.g. the
1414    /// app was backgrounded) as a signal to trigger a full collection resync
1415    /// rather than relying on the change stream alone.
1416    pub fn subscribe_poll(&self) -> Result<Arc<SubscriptionHandle>, PeatError> {
1417        // Subscribe to ALL changes (local + peer-synced) via the origin-tagged channel.
1418        //
1419        // The gossip channel fires on every Automerge sync protocol exchange, including
1420        // redundant re-syncs of unchanged documents. To prevent a sync loop (periodic
1421        // requestSync re-fires Remote events for every already-known doc), we apply
1422        // origin-based deduplication:
1423        // Emit all events — dedup is handled in the Dart layer via content
1424        // hashing so cross-device updates (including repeated increments)
1425        // are never silently dropped by the Rust subscription.
1426        let change_rx = self.store.subscribe_to_changes_with_origin();
1427        let active = Arc::new(AtomicBool::new(true));
1428        let active_clone = Arc::clone(&active);
1429        let pending = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
1430            DocumentChange,
1431        >::new()));
1432        let pending_clone = Arc::clone(&pending);
1433
1434        self.runtime.spawn(async move {
1435            let mut rx = change_rx;
1436            while active_clone.load(Ordering::SeqCst) {
1437                tokio::select! {
1438                    result = rx.recv() => {
1439                        match result {
1440                            Ok(doc_change) => {
1441                                let doc_key = doc_change.key;
1442                                let change = if let Some((collection, doc_id)) = doc_key.split_once(':') {
1443                                    DocumentChange {
1444                                        collection: collection.to_string(),
1445                                        doc_id: doc_id.to_string(),
1446                                        change_type: ChangeType::Upsert,
1447                                    }
1448                                } else {
1449                                    DocumentChange {
1450                                        collection: "default".to_string(),
1451                                        doc_id: doc_key,
1452                                        change_type: ChangeType::Upsert,
1453                                    }
1454                                };
1455                                if let Ok(mut q) = pending_clone.lock() {
1456                                    q.push_back(change);
1457                                }
1458                            }
1459                            Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1460                            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1461                        }
1462                    }
1463                    _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
1464                        if !active_clone.load(Ordering::SeqCst) {
1465                            break;
1466                        }
1467                    }
1468                }
1469            }
1470        });
1471
1472        Ok(Arc::new(SubscriptionHandle::new_with_queue(
1473            active, pending,
1474        )))
1475    }
1476}
1477
1478/// Create a new PeatNode with FormationKey authentication
1479///
1480/// Requires `app_id` and `shared_key` for peer authentication.
1481/// Only peers with matching credentials can connect and sync.
1482///
1483/// # Arguments
1484///
1485/// * `config` - Node configuration including:
1486///   - `app_id`: Formation/application identifier (use same value for all nodes
1487///     in your swarm)
1488///   - `shared_key`: Base64-encoded 32-byte secret key (generate with `openssl
1489///     rand -base64 32`)
1490///   - `bind_address`: Optional address to bind (default: "0.0.0.0:0")
1491///   - `storage_path`: Directory for persistent storage
1492///
1493/// Note: This function is NOT async because we manage our own Tokio runtime
1494/// to ensure proper context for Iroh transport operations.
1495#[cfg(feature = "sync")]
1496#[uniffi::export]
1497pub fn create_node(config: NodeConfig) -> Result<Arc<PeatNode>, PeatError> {
1498    use std::time::Instant;
1499    let total_start = Instant::now();
1500
1501    // Validate credentials
1502    if config.app_id.is_empty() {
1503        return Err(PeatError::InvalidInput {
1504            msg: "app_id cannot be empty".to_string(),
1505        });
1506    }
1507    if config.shared_key.is_empty() {
1508        return Err(PeatError::InvalidInput {
1509            msg: "shared_key cannot be empty".to_string(),
1510        });
1511    }
1512
1513    // Helper: read RSS from /proc/self/status
1514    fn get_rss_kb() -> u64 {
1515        std::fs::read_to_string("/proc/self/status")
1516            .ok()
1517            .and_then(|s| {
1518                s.lines()
1519                    .find(|l| l.starts_with("VmRSS:"))
1520                    .and_then(|l| l.split_whitespace().nth(1))
1521                    .and_then(|v| v.parse().ok())
1522            })
1523            .unwrap_or(0)
1524    }
1525
1526    #[cfg(target_os = "android")]
1527    android_log(&format!("[MEM] Before runtime: {} kB", get_rss_kb()));
1528
1529    // TIMING: Create runtime
1530    let phase_start = Instant::now();
1531
1532    // Create a dedicated Tokio runtime for this node
1533    // Use 4 worker threads to avoid starving BLE D-Bus tasks when Iroh
1534    // background tasks (discovery, relay, pkarr) are running concurrently.
1535    let runtime = tokio::runtime::Builder::new_multi_thread()
1536        .worker_threads(4)
1537        .enable_all()
1538        .build()
1539        .map_err(|e| PeatError::SyncError {
1540            msg: format!("Failed to create runtime: {}", e),
1541        })?;
1542
1543    let runtime_ms = phase_start.elapsed().as_millis();
1544    #[cfg(target_os = "android")]
1545    android_log(&format!("[TIMING] Runtime creation: {}ms", runtime_ms));
1546    #[cfg(target_os = "android")]
1547    android_log(&format!("[MEM] After runtime: {} kB", get_rss_kb()));
1548    #[cfg(not(target_os = "android"))]
1549    eprintln!("[Peat TIMING] Runtime creation: {}ms", runtime_ms);
1550
1551    // Parse bind address
1552    let bind_addr: SocketAddr = config
1553        .bind_address
1554        .as_deref()
1555        .unwrap_or("0.0.0.0:0")
1556        .parse()
1557        .map_err(|e| PeatError::InvalidInput {
1558            msg: format!("Invalid bind address: {}", e),
1559        })?;
1560
1561    // Create storage path
1562    let storage_path = PathBuf::from(&config.storage_path);
1563    std::fs::create_dir_all(&storage_path).map_err(|e| PeatError::StorageError {
1564        msg: format!("Failed to create storage directory: {}", e),
1565    })?;
1566
1567    // TIMING: Parallel store + transport initialization
1568    let phase_start = Instant::now();
1569
1570    // OPTIMIZATION: Run store opening and transport creation in parallel
1571    // These are independent operations that can overlap to reduce startup time.
1572    // - AutomergeStore::open() is blocking I/O (redb database)
1573    // - IrohTransport creation is async (QUIC endpoint binding)
1574    //
1575    // OPTIMIZATION: Use fast constructor WITHOUT mDNS discovery for faster startup.
1576    // mDNS discovery is deferred until after the sync backend is initialized.
1577    // This reduces "startup intensity" that was causing Docker API timeouts
1578    // in large-scale deployments (see 384-node hierarchical simulations).
1579    let seed = format!("{}/{}", config.app_id, config.storage_path);
1580    let storage_path_for_store = storage_path.clone();
1581    // Runtime relay posture (peat-flutter relay toggle): opt into n0's hosted
1582    // public relay pool only when the caller asked for it. Defaults to the
1583    // local-only posture so unconfigured callers don't phone home.
1584    let enable_n0_relay = config
1585        .transport
1586        .as_ref()
1587        .map(|t| t.enable_n0_relay)
1588        .unwrap_or(false);
1589
1590    let (store, transport, store_ms, transport_ms) = runtime.block_on(async {
1591        let store_start = Instant::now();
1592        let transport_start = Instant::now();
1593
1594        // Spawn store opening on blocking thread pool (it does sync I/O).
1595        // Retry up to 10 times with 200 ms delays — the previous node's redb
1596        // file lock may not be released immediately when the user stops and
1597        // immediately restarts the node (background Arcs are still alive).
1598        let store_handle = tokio::task::spawn_blocking(move || {
1599            let mut last_err = None;
1600            // Retry for up to ~30 s — iOS background tasks can hold the redb
1601            // lock for longer than macOS before their Arcs are fully released.
1602            for _ in 0..60u32 {
1603                match AutomergeStore::open(&storage_path_for_store) {
1604                    Ok(s) => return (Ok(s), store_start.elapsed().as_millis()),
1605                    Err(e) => {
1606                        last_err = Some(e);
1607                        std::thread::sleep(std::time::Duration::from_millis(500));
1608                    }
1609                }
1610            }
1611            (Err(last_err.unwrap()), store_start.elapsed().as_millis())
1612        });
1613
1614        // Create transport WITH mDNS discovery wired into the endpoint
1615        let transport_future = async {
1616            let result =
1617                IrohTransport::from_seed_with_discovery_at_addr(&seed, bind_addr, enable_n0_relay)
1618                    .await;
1619            (result, transport_start.elapsed().as_millis())
1620        };
1621
1622        // Wait for both to complete
1623        let (store_result, transport_result) = tokio::join!(store_handle, transport_future);
1624
1625        // Unwrap the JoinHandle result first, then the actual result
1626        let (store_inner, store_elapsed) = store_result.map_err(|e| PeatError::StorageError {
1627            msg: format!("Store task panicked: {}", e),
1628        })?;
1629        let store = store_inner.map_err(|e| PeatError::StorageError {
1630            msg: format!("Failed to open store: {}", e),
1631        })?;
1632
1633        #[cfg(target_os = "android")]
1634        android_log(&format!(
1635            "[MEM] After store open: {} kB (store {}ms)",
1636            get_rss_kb(),
1637            store_elapsed
1638        ));
1639
1640        let (transport_inner, transport_elapsed) = transport_result;
1641        let transport = transport_inner.map_err(|e| PeatError::ConnectionError {
1642            msg: format!("Failed to create transport with mDNS: {}", e),
1643        })?;
1644
1645        #[cfg(target_os = "android")]
1646        android_log(&format!(
1647            "[MEM] After iroh transport: {} kB (transport {}ms)",
1648            get_rss_kb(),
1649            transport_elapsed
1650        ));
1651
1652        Ok::<_, PeatError>((
1653            Arc::new(store),
1654            Arc::new(transport),
1655            store_elapsed,
1656            transport_elapsed,
1657        ))
1658    })?;
1659
1660    let parallel_total_ms = phase_start.elapsed().as_millis();
1661    #[cfg(target_os = "android")]
1662    {
1663        android_log(&format!("[TIMING] Store open: {}ms", store_ms));
1664        android_log(&format!(
1665            "[TIMING] Transport create (with mDNS): {}ms",
1666            transport_ms
1667        ));
1668        android_log(&format!(
1669            "[TIMING] Parallel total (max of above): {}ms",
1670            parallel_total_ms
1671        ));
1672    }
1673    #[cfg(not(target_os = "android"))]
1674    {
1675        eprintln!("[Peat TIMING] Store open: {}ms", store_ms);
1676        eprintln!(
1677            "[Peat TIMING] Transport create (with mDNS): {}ms",
1678            transport_ms
1679        );
1680        eprintln!(
1681            "[Peat TIMING] Parallel total (max of above): {}ms",
1682            parallel_total_ms
1683        );
1684    }
1685
1686    // Create storage backend with transport
1687    let storage_backend = Arc::new(AutomergeBackend::with_transport(
1688        Arc::clone(&store),
1689        Arc::clone(&transport),
1690    ));
1691
1692    // Create sync backend (AutomergeIrohBackend) for authenticated P2P sync
1693    // Note: AutomergeIrohBackend wraps storage::AutomergeBackend for the
1694    // DataSyncBackend trait
1695    let sync_backend = Arc::new(AutomergeIrohBackend::new(
1696        Arc::clone(&storage_backend),
1697        Arc::clone(&transport),
1698    ));
1699
1700    // IMPORTANT (Issue #275): Subscribe to peer events BEFORE initializing sync
1701    // backend. The initialize() call spawns the accept loop, so we need to
1702    // subscribe first to catch all connection events including the initial
1703    // ones.
1704    let mut event_rx = transport.subscribe_peer_events();
1705
1706    // TIMING: Sync backend initialization
1707    let phase_start = Instant::now();
1708
1709    // Initialize sync backend with credentials for FormationKey authentication
1710    let backend_config = BackendConfig {
1711        app_id: config.app_id.clone(),
1712        persistence_dir: storage_path.clone(),
1713        shared_key: Some(config.shared_key.clone()),
1714        transport: TransportConfig::default(),
1715        extra: std::collections::HashMap::new(),
1716    };
1717
1718    runtime.block_on(async {
1719        sync_backend
1720            .initialize(backend_config)
1721            .await
1722            .map_err(|e| PeatError::SyncError {
1723                msg: format!("Failed to initialize sync backend: {}", e),
1724            })
1725    })?;
1726
1727    let sync_init_ms = phase_start.elapsed().as_millis();
1728    #[cfg(target_os = "android")]
1729    {
1730        android_log(&format!("[TIMING] Sync backend init: {}ms", sync_init_ms));
1731        android_log("=== sync_backend.initialize() completed successfully ===");
1732    }
1733    #[cfg(not(target_os = "android"))]
1734    eprintln!("[Peat TIMING] Sync backend init: {}ms", sync_init_ms);
1735
1736    // Start background task to listen for peer events and forward to Java (Issue
1737    // #275)
1738    let cleanup_running = Arc::new(AtomicBool::new(true));
1739    let cleanup_flag = Arc::clone(&cleanup_running);
1740    let runtime_arc = Arc::new(runtime);
1741
1742    // Clone transport for the cleanup task
1743    let transport_for_cleanup = Arc::clone(&transport);
1744
1745    // Log that we're starting the peer event listener
1746    #[cfg(target_os = "android")]
1747    android_log("Starting peer event listener task (Issue #275)");
1748
1749    runtime_arc.spawn(async move {
1750        #[cfg(target_os = "android")]
1751        android_log("Peer event listener task running");
1752
1753        while cleanup_flag.load(Ordering::Relaxed) {
1754            tokio::select! {
1755                event_result = event_rx.recv() => {
1756                    match event_result {
1757                        Some(event) => {
1758                            #[cfg(target_os = "android")]
1759                            android_log(&format!("Received transport peer event: {:?}", event));
1760
1761                            match event {
1762                                TransportPeerEvent::Connected { endpoint_id, .. } => {
1763                                    let peer_id = hex::encode(endpoint_id.as_bytes());
1764                                    #[cfg(target_os = "android")]
1765                                    android_log(&format!("Processing Connected event for peer: {}", peer_id));
1766                                    notify_peer_connected(&peer_id);
1767                                }
1768                                TransportPeerEvent::Disconnected { endpoint_id, reason } => {
1769                                    let peer_id = hex::encode(endpoint_id.as_bytes());
1770                                    #[cfg(target_os = "android")]
1771                                    android_log(&format!("Processing Disconnected event for peer: {} reason: {}", peer_id, reason));
1772                                    notify_peer_disconnected(&peer_id, &reason);
1773                                }
1774                            }
1775                        }
1776                        None => {
1777                            #[cfg(target_os = "android")]
1778                            android_log("Event channel closed, exiting peer event listener");
1779                            break;
1780                        }
1781                    }
1782                }
1783                _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
1784                    // Periodically call peer_count() to trigger cleanup_closed_connections()
1785                    // This detects dead connections and emits Disconnected events
1786                    let count = transport_for_cleanup.peer_count();
1787                    #[cfg(target_os = "android")]
1788                    android_log(&format!("Periodic cleanup tick - peer count: {}", count));
1789                }
1790            }
1791        }
1792
1793        #[cfg(target_os = "android")]
1794        android_log("Peer event listener task exiting");
1795    });
1796
1797    // IMPORTANT (Issue #378): Use the storage_backend from sync_backend, NOT a new
1798    // one! Creating a separate AutomergeBackend would cause sync coordinator
1799    // state to be split, resulting in data not being received from peers.
1800    let storage_backend = sync_backend.storage_backend();
1801
1802    // Create TransportManager for multi-transport coordination (ADR-032, #555)
1803    // Build TransportManagerConfig from FFI config (PACE policy + collection
1804    // routes)
1805    let mut tm_config = TransportManagerConfig::default();
1806
1807    if let Some(ref transport_config) = config.transport {
1808        // Build PACE policy from transport_preference
1809        if let Some(ref prefs) = transport_config.transport_preference {
1810            let policy = TransportPolicy::new("ffi-config").primary(prefs.clone());
1811            tm_config.default_policy = Some(policy);
1812        }
1813
1814        // Parse collection routes from JSON
1815        if let Some(ref routes_json) = transport_config.collection_routes_json {
1816            match serde_json::from_str::<CollectionRouteTable>(routes_json) {
1817                Ok(table) => {
1818                    tm_config.collection_routes = table;
1819                }
1820                Err(e) => {
1821                    eprintln!("[Peat] Failed to parse collection_routes_json: {}", e);
1822                }
1823            }
1824        }
1825    }
1826
1827    let mut transport_manager = TransportManager::new(tm_config);
1828
1829    // Create IrohMeshTransport wrapper and register with TransportManager.
1830    // This allows the transport to be selected via PACE policy alongside
1831    // future transports.
1832    //
1833    // ADR-062 Phase 2 (peat#926): peat-mesh's IrohMeshTransport takes
1834    // `Vec<PeerInfo>` directly instead of `Arc<RwLock<PeerConfig>>` — the
1835    // `formation` and `local` fields of PeerConfig were never used by the
1836    // transport itself; they remain in peat-protocol's security layer.
1837    // peat-ffi starts with an empty static-peer list; runtime peer
1838    // additions go through `iroh_mesh_transport.set_static_peers(...)`.
1839    let iroh_mesh_transport = Arc::new(IrohMeshTransport::new(Arc::clone(&transport), Vec::new()));
1840    let iroh_as_transport: Arc<dyn Transport> = iroh_mesh_transport.clone();
1841    transport_manager.register(iroh_as_transport.clone());
1842
1843    // Register as PACE instance for collection routing
1844    let iroh_instance = TransportInstance::new(
1845        "iroh-primary",
1846        TransportType::Quic,
1847        TransportCapabilities::quic(),
1848    )
1849    .with_description("Primary Iroh/QUIC transport");
1850    transport_manager.register_instance(iroh_instance, iroh_as_transport);
1851
1852    // Initialize BLE transport if enabled (ADR-039, #556)
1853    #[cfg(feature = "bluetooth")]
1854    if let Some(ref transport_config) = config.transport {
1855        if transport_config.enable_ble {
1856            #[cfg(target_os = "android")]
1857            {
1858                use peat_btle::platform::android::AndroidAdapter;
1859                use peat_btle::{BleConfig, BluetoothLETransport};
1860
1861                android_log("BLE transport requested - initializing AndroidAdapter stub");
1862
1863                // Derive BLE node ID from Iroh endpoint key (same as Linux path)
1864                let iroh_endpoint_id = transport.endpoint_id();
1865                let iroh_key_bytes = iroh_endpoint_id.as_bytes();
1866                let ble_node_id = peat_btle::NodeId::new(u32::from_be_bytes([
1867                    iroh_key_bytes[28],
1868                    iroh_key_bytes[29],
1869                    iroh_key_bytes[30],
1870                    iroh_key_bytes[31],
1871                ]));
1872                let ble_config = BleConfig::new(ble_node_id);
1873                let adapter = AndroidAdapter::new_stub();
1874                let btle = BluetoothLETransport::new(ble_config, adapter);
1875                let ble_transport = Arc::new(PeatBleTransport::new(btle));
1876                let ble_as_transport: Arc<dyn Transport> = ble_transport.clone();
1877                transport_manager.register(ble_as_transport.clone());
1878
1879                // Register as PACE instance for collection routing
1880                let ble_instance = TransportInstance::new(
1881                    "ble-primary",
1882                    TransportType::BluetoothLE,
1883                    TransportCapabilities::bluetooth_le(),
1884                )
1885                .with_description("Primary BLE transport (Android)");
1886                transport_manager.register_instance(ble_instance, ble_as_transport);
1887
1888                // Store in global for JNI access
1889                *ANDROID_BLE_TRANSPORT.lock().unwrap() = Some(ble_transport);
1890
1891                android_log("BLE transport registered as PACE instance 'ble-primary'");
1892            }
1893
1894            #[cfg(not(target_os = "android"))]
1895            {
1896                // On non-Android platforms, we can initialize BLE directly
1897                // Linux uses BluerAdapter, macOS uses CoreBluetoothAdapter
1898                #[cfg(target_os = "linux")]
1899                {
1900                    use peat_btle::platform::linux::BluerAdapter;
1901                    use peat_btle::{BleAdapter, BleConfig, BluetoothLETransport, PowerProfile};
1902
1903                    // Parse power profile from config
1904                    let power_profile = match transport_config.ble_power_profile.as_deref() {
1905                        Some("aggressive") => PowerProfile::Aggressive,
1906                        Some("low_power") => PowerProfile::LowPower,
1907                        _ => PowerProfile::Balanced,
1908                    };
1909
1910                    // Derive a 32-bit BLE node ID from the Iroh endpoint's public key
1911                    // Use last 4 bytes of the 32-byte key for a unique-enough identifier
1912                    let iroh_endpoint_id = transport.endpoint_id();
1913                    let iroh_key_bytes = iroh_endpoint_id.as_bytes();
1914                    let ble_node_id = peat_btle::NodeId::new(u32::from_be_bytes([
1915                        iroh_key_bytes[28],
1916                        iroh_key_bytes[29],
1917                        iroh_key_bytes[30],
1918                        iroh_key_bytes[31],
1919                    ]));
1920
1921                    // Create BLE config with node ID, power profile, and mesh ID
1922                    let mut ble_config = BleConfig::new(ble_node_id);
1923                    ble_config.power_profile = power_profile;
1924                    if let Some(ref mesh_id) = transport_config.ble_mesh_id {
1925                        ble_config.mesh.mesh_id = mesh_id.clone();
1926                    }
1927
1928                    // Create BLE transport with BluerAdapter
1929                    // IMPORTANT: All async BLE operations (create adapter, init, register
1930                    // GATT, start advertising/scanning) MUST happen in a single block_on().
1931                    // Splitting into two block_on() calls suspends the tokio runtime between
1932                    // them, which can cause the GATT ApplicationHandle's D-Bus registration
1933                    // to be dropped before advertising starts — making the GATT service
1934                    // intermittently invisible to remote devices.
1935                    //
1936                    // Brings `MeshTransport` into scope so `ble_transport.start()` resolves;
1937                    // mirrors the import at the other start() call site (line ~3259).
1938                    use peat_protocol::transport::MeshTransport;
1939                    match runtime_arc.block_on(async {
1940                        let mut adapter = BluerAdapter::new().await?;
1941
1942                        // Initialize adapter with config (stores node ID, mesh ID, etc.)
1943                        adapter.init(&ble_config).await?;
1944
1945                        // Register GATT service with BlueZ so peers can connect
1946                        adapter.register_gatt_service().await?;
1947
1948                        // Wrap in transport layers
1949                        let btle = BluetoothLETransport::new(ble_config, adapter);
1950                        let ble_transport = Arc::new(PeatBleTransport::new(btle));
1951
1952                        // Start advertising and scanning in the same async context
1953                        ble_transport.start().await.map_err(|e| {
1954                            peat_btle::BleError::PlatformError(format!(
1955                                "Failed to start BLE transport: {}",
1956                                e
1957                            ))
1958                        })?;
1959
1960                        Ok::<_, peat_btle::BleError>(ble_transport)
1961                    }) {
1962                        Ok(ble_transport) => {
1963                            let ble_as_transport: Arc<dyn Transport> = ble_transport.clone();
1964                            transport_manager.register(ble_as_transport.clone());
1965
1966                            // Register as PACE instance for collection routing
1967                            let ble_instance = TransportInstance::new(
1968                                "ble-primary",
1969                                TransportType::BluetoothLE,
1970                                TransportCapabilities::bluetooth_le(),
1971                            )
1972                            .with_description("Primary BLE transport");
1973                            transport_manager.register_instance(ble_instance, ble_as_transport);
1974                            eprintln!(
1975                                "[Peat] BLE transport registered as PACE instance 'ble-primary'"
1976                            );
1977                        }
1978                        Err(e) => {
1979                            eprintln!("[Peat] Failed to initialize BLE adapter: {} (continuing without BLE)", e);
1980                        }
1981                    }
1982                }
1983
1984                #[cfg(not(target_os = "linux"))]
1985                eprintln!(
1986                    "[Peat] BLE transport requested but not yet implemented for this platform"
1987                );
1988            }
1989        }
1990    }
1991
1992    // TIMING: Total startup time
1993    let total_ms = total_start.elapsed().as_millis();
1994    #[cfg(target_os = "android")]
1995    android_log(&format!(
1996        "[TIMING] === TOTAL create_node: {}ms ===",
1997        total_ms
1998    ));
1999    #[cfg(not(target_os = "android"))]
2000    eprintln!("[Peat TIMING] === TOTAL create_node: {}ms ===", total_ms);
2001
2002    // Compose `peat_mesh::Node` over the same `AutomergeIrohBackend` the
2003    // existing typed surface uses. Both layers see the same underlying
2004    // doc store; the Node adds a generic publish/observe surface for
2005    // doc-type-agnostic callers (the `ingest*Jni` family, future
2006    // per-doc-type typed wrappers).
2007    #[cfg(feature = "sync")]
2008    let node = {
2009        use peat_mesh::sync::traits::DataSyncBackend;
2010        let backend_dyn: Arc<dyn DataSyncBackend> = sync_backend.clone();
2011        Arc::new(peat_mesh::Node::new(backend_dyn))
2012    };
2013
2014    // BleTranslator: BLE-typed structs ↔ Automerge documents (ADR-041).
2015    // Built only when the bluetooth feature is enabled. Used by the
2016    // `ingest*Jni` family of methods + (Slice 1.b.2.2) the
2017    // `OutboundFrameCallback` JNI surface.
2018    #[cfg(all(feature = "sync", feature = "bluetooth"))]
2019    let ble_translator = {
2020        use peat_protocol::sync::ble_translation::BleTranslator;
2021        Arc::new(BleTranslator::with_defaults())
2022    };
2023
2024    let node_arc = Arc::new(PeatNode {
2025        sync_backend,
2026        storage_backend,
2027        #[cfg(feature = "sync")]
2028        node,
2029        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2030        ble_translator,
2031        transport_manager,
2032        iroh_transport: transport,
2033        store,
2034        #[cfg(feature = "sync")]
2035        water_counter: water_counter::WaterCounter::load_or_init(
2036            storage_path.join("water.automerge"),
2037        ),
2038        #[cfg(feature = "sync")]
2039        crdt_kv: crdt_kv::CrdtKvDocs::new(storage_path.clone()),
2040        storage_path,
2041        runtime: runtime_arc,
2042        cleanup_running,
2043        #[cfg(feature = "sync")]
2044        blob_store: std::sync::RwLock::new(None),
2045        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2046        outbound_queue: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
2047        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2048        outbound_fanout: std::sync::Mutex::new(None),
2049        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2050        relay_seen: std::sync::Mutex::new(std::collections::HashMap::new()),
2051    });
2052
2053    // Publish an OWNING reference to the JNI-visible global so a Kotlin bridge
2054    // (e.g. the BLE pipe) can reach a node created via the Dart/UniFFI path
2055    // without risking use-after-free: the prior code stashed a non-owning
2056    // alias whose sole owner was the Dart handle, so Dart's GC finalizer could
2057    // free the node out from under a `getGlobalNodeHandleJni` consumer.
2058    //
2059    // Android-only: the global is consumed solely by the JNI bridges (BLE /
2060    // Wi-Fi Direct). iOS reaches BLE via the independent UniFFI poll bridge and
2061    // never reads it, so storing an owning Arc there would only leak — the
2062    // node's sole owner on iOS must be the Dart UniFFI handle so `close()`/
2063    // dispose actually drops it and releases the redb file lock. Without this
2064    // gate, an in-app Stop on iOS left the node (and its redb store) alive, so
2065    // the next Start hit "Failed to open redb database" on the still-locked
2066    // file. iOS has no `clearGlobalNodeHandleJni` counterpart to release it.
2067    #[cfg(target_os = "android")]
2068    set_global_node_handle(&node_arc);
2069    Ok(node_arc)
2070}
2071
2072// Add new error variants for sync operations
2073#[cfg(feature = "sync")]
2074impl From<anyhow::Error> for PeatError {
2075    fn from(e: anyhow::Error) -> Self {
2076        PeatError::SyncError { msg: e.to_string() }
2077    }
2078}
2079
2080// =============================================================================
2081// Peat Data Types for Consumer Integration
2082// =============================================================================
2083//
2084// These types represent Peat entities that can be synced and displayed by
2085// consumer plugins. They use well-known collection names for document storage.
2086
2087/// Well-known collection names for Peat data
2088pub mod collections {
2089    /// Collection for Peat cells (teams/squads)
2090    pub const CELLS: &str = "cells";
2091    /// Collection for detected tracks (entities being tracked)
2092    pub const TRACKS: &str = "tracks";
2093    /// Collection for nodes (robots, drones, sensors)
2094    pub const NODES: &str = "nodes";
2095    /// Collection for capability advertisements
2096    pub const CAPABILITIES: &str = "capabilities";
2097    /// Collection for commands (C2 messages)
2098    pub const COMMANDS: &str = "commands";
2099    /// Collection for operator-placed map markers (CoT pins synced
2100    /// across the mesh via the universal-Document transport,
2101    /// ADR-035). Receiver renders consistently regardless of which
2102    /// peer originated the marker — the doc store is the source of
2103    /// truth, transport is invisible to consumers.
2104    pub const MARKERS: &str = "markers";
2105}
2106
2107/// CoT 2525 placeholder type that
2108/// [`parse_marker_publish_json`] substitutes when a tombstone body
2109/// arrives without an explicit `type` field. Tombstones intentionally
2110/// omit geo + type to keep the BLE frame tight (~40 bytes vs ~120
2111/// for a full marker); receivers filter `_deleted: true` entries out
2112/// of "current markers" views before the placeholder is rendered, so
2113/// the value never reaches a UI. Lifted to a named constant so a
2114/// future change to the placeholder shape (e.g., shifting to a
2115/// neutral "unknown" or an empty string) lands in one place rather
2116/// than being scattered through the parser.
2117const TOMBSTONE_PLACEHOLDER_TYPE: &str = "a-u-G";
2118
2119/// Cell status enumeration
2120#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2121pub enum CellStatus {
2122    /// Cell is active and operational
2123    Active,
2124    /// Cell is forming (members joining)
2125    Forming,
2126    /// Cell has degraded capability
2127    Degraded,
2128    /// Cell is offline
2129    Offline,
2130}
2131
2132impl CellStatus {
2133    fn from_str(s: &str) -> Self {
2134        match s.to_uppercase().as_str() {
2135            "ACTIVE" => Self::Active,
2136            "FORMING" => Self::Forming,
2137            "DEGRADED" => Self::Degraded,
2138            "OFFLINE" => Self::Offline,
2139            _ => Self::Offline,
2140        }
2141    }
2142
2143    fn as_str(&self) -> &'static str {
2144        match self {
2145            Self::Active => "ACTIVE",
2146            Self::Forming => "FORMING",
2147            Self::Degraded => "DEGRADED",
2148            Self::Offline => "OFFLINE",
2149        }
2150    }
2151}
2152
2153/// Peat Cell information for display
2154#[derive(Debug, Clone, uniffi::Record)]
2155pub struct CellInfo {
2156    /// Unique cell identifier
2157    pub id: String,
2158    /// Human-readable cell name (e.g., "Alpha Team")
2159    pub name: String,
2160    /// Cell status
2161    pub status: CellStatus,
2162    /// Number of nodes in this cell
2163    pub node_count: u32,
2164    /// Center latitude (WGS84)
2165    pub center_lat: f64,
2166    /// Center longitude (WGS84)
2167    pub center_lon: f64,
2168    /// List of capabilities (e.g., ["OBJECT_TRACKING", "COMMUNICATION"])
2169    pub capabilities: Vec<String>,
2170    /// Parent formation ID (if any)
2171    pub formation_id: Option<String>,
2172    /// Cell leader node ID (if any)
2173    pub leader_id: Option<String>,
2174    /// Last update timestamp (Unix millis)
2175    pub last_update: i64,
2176    /// Optional scenario command piggybacked on cell (e.g., "START_SCENARIO",
2177    /// "STOP_SCENARIO")
2178    pub scenario_command: Option<String>,
2179}
2180
2181/// Track category enumeration
2182#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2183pub enum TrackCategory {
2184    Person,
2185    Vehicle,
2186    Aircraft,
2187    Vessel,
2188    Installation,
2189    Unknown,
2190}
2191
2192impl TrackCategory {
2193    fn from_str(s: &str) -> Self {
2194        match s.to_uppercase().as_str() {
2195            "PERSON" => Self::Person,
2196            "VEHICLE" => Self::Vehicle,
2197            "AIRCRAFT" => Self::Aircraft,
2198            "VESSEL" => Self::Vessel,
2199            "INSTALLATION" => Self::Installation,
2200            _ => Self::Unknown,
2201        }
2202    }
2203
2204    fn as_str(&self) -> &'static str {
2205        match self {
2206            Self::Person => "PERSON",
2207            Self::Vehicle => "VEHICLE",
2208            Self::Aircraft => "AIRCRAFT",
2209            Self::Vessel => "VESSEL",
2210            Self::Installation => "INSTALLATION",
2211            Self::Unknown => "UNKNOWN",
2212        }
2213    }
2214}
2215
2216/// Track information for display
2217#[derive(Debug, Clone, uniffi::Record)]
2218pub struct TrackInfo {
2219    /// Unique track identifier
2220    pub id: String,
2221    /// Source node that detected this track
2222    pub source_node: String,
2223    /// Cell ID that owns this track (if any)
2224    pub cell_id: Option<String>,
2225    /// Formation ID (if any)
2226    pub formation_id: Option<String>,
2227    /// Track latitude (WGS84)
2228    pub lat: f64,
2229    /// Track longitude (WGS84)
2230    pub lon: f64,
2231    /// Height above ellipsoid (meters, optional)
2232    pub hae: Option<f64>,
2233    /// Circular error probable (meters, optional)
2234    pub cep: Option<f64>,
2235    /// Heading in degrees (0 = North, optional)
2236    pub heading: Option<f64>,
2237    /// Speed in m/s (optional)
2238    pub speed: Option<f64>,
2239    /// MIL-STD-2525 classification or category
2240    pub classification: String,
2241    /// Detection confidence (0.0 - 1.0)
2242    pub confidence: f64,
2243    /// Track category
2244    pub category: TrackCategory,
2245    /// Created timestamp (Unix millis)
2246    pub created_at: i64,
2247    /// Last update timestamp (Unix millis)
2248    pub last_update: i64,
2249    /// Additional key-value attributes (callsign, image chip data, etc.)
2250    pub attributes: HashMap<String, String>,
2251}
2252
2253/// Node status enumeration
2254#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2255pub enum NodeStatus {
2256    /// Node is ready
2257    Ready,
2258    /// Node is active
2259    Active,
2260    /// Node has degraded capability
2261    Degraded,
2262    /// Node is offline
2263    Offline,
2264    /// Node is loading/initializing
2265    Loading,
2266}
2267
2268impl NodeStatus {
2269    fn from_str(s: &str) -> Self {
2270        match s.to_uppercase().as_str() {
2271            "READY" => Self::Ready,
2272            "ACTIVE" => Self::Active,
2273            "DEGRADED" => Self::Degraded,
2274            "OFFLINE" => Self::Offline,
2275            "LOADING" => Self::Loading,
2276            _ => Self::Offline,
2277        }
2278    }
2279
2280    pub fn as_str(&self) -> &'static str {
2281        match self {
2282            Self::Ready => "READY",
2283            Self::Active => "ACTIVE",
2284            Self::Degraded => "DEGRADED",
2285            Self::Offline => "OFFLINE",
2286            Self::Loading => "LOADING",
2287        }
2288    }
2289}
2290
2291/// Node information for display
2292#[derive(Debug, Clone, uniffi::Record)]
2293pub struct NodeInfo {
2294    /// Unique node identifier
2295    pub id: String,
2296    /// Node type (e.g., "UGV", "UAV", "Soldier System")
2297    pub node_type: String,
2298    /// Node name/callsign
2299    pub name: String,
2300    /// Node status
2301    pub status: NodeStatus,
2302    /// Node latitude (WGS84)
2303    pub lat: f64,
2304    /// Node longitude (WGS84)
2305    pub lon: f64,
2306    /// Height above ellipsoid (meters, optional)
2307    pub hae: Option<f64>,
2308    /// Readiness level (0.0 - 1.0)
2309    pub readiness: f64,
2310    /// List of capabilities
2311    pub capabilities: Vec<String>,
2312    /// Cell membership (if any)
2313    pub cell_id: Option<String>,
2314    /// Battery / fuel percentage (0–100). Optional because not every
2315    /// node has a measurable battery (fixed sensors, pre-lock
2316    /// watches), and legacy publishes from pre-2026-05-08 hosts didn't
2317    /// carry the field. Wire key: `battery_percent`. See
2318    /// [`parse_battery_percent`] for the clamp + None semantics.
2319    pub battery_percent: Option<i32>,
2320    /// Heart rate in BPM, sourced from wearable sensors (WearOS watch,
2321    /// M5Stack health). Wire key: `heart_rate`. Required to surface a
2322    /// vitals indicator on the operator card; absent on node types
2323    /// that don't carry a wearable. See [`parse_heart_rate`] for the
2324    /// clamp + None semantics.
2325    pub heart_rate: Option<i32>,
2326    /// Last heartbeat timestamp (Unix millis). Defaults to `0` when
2327    /// the publisher omits the field, surfaced to the UI as
2328    /// "1970-01-01 stale" — different intent from `battery_percent`'s
2329    /// `None` ("unknown sensor state"). Don't fold this into the same
2330    /// `Option<T>` shape: a missing heartbeat *is* a stale-record
2331    /// signal, not absence-of-data, and the node-overlay code uses
2332    /// the time delta directly without a None-check branch.
2333    pub last_heartbeat: i64,
2334}
2335
2336/// Operator-placed map marker — the typed shape every peer renders
2337/// in the Peat Markers panel and on the MapView (ADR-035 Universal
2338/// Document transport, "markers" collection).
2339///
2340/// Origin-agnostic: this struct is what the local doc store holds,
2341/// independent of which peer published it. The plugin's mental model
2342/// is "created somewhere, synced everywhere, displayed consistently"
2343/// — `MarkerInfo` is the synced shape, the wire transport is
2344/// invisible above this surface.
2345///
2346/// Wire-key parity with the JSON the prior raw-JSON publish path
2347/// produced (uid, type, lat, lon, hae, ts, callsign, color), so the
2348/// migration to the typed API is wire-compatible: docs published by
2349/// the old raw-JSON path round-trip cleanly into `MarkerInfo`.
2350#[derive(Debug, Clone, uniffi::Record)]
2351pub struct MarkerInfo {
2352    /// Unique marker identifier — the operator-placed UID, typically
2353    /// UUID-shaped (e.g. `4ae7b0a0-1995-447c-...`).
2354    pub uid: String,
2355    /// CoT 2525-style type code (e.g. `"a-f-G-U-C"` for friendly
2356    /// ground unit combat, `"b-m-p-w"` for waypoint).
2357    pub marker_type: String,
2358    /// Latitude (WGS84).
2359    pub lat: f64,
2360    /// Longitude (WGS84).
2361    pub lon: f64,
2362    /// Height above ellipsoid (meters). `None` when the publisher
2363    /// had no altitude fix; receivers render at ground level.
2364    pub hae: Option<f64>,
2365    /// Unix epoch milliseconds — the publisher's clock at marker
2366    /// drop time. Receivers DON'T treat this as a presence-staleness
2367    /// timestamp (markers persist until deleted, unlike nodes);
2368    /// it's purely "when did the operator drop this pin."
2369    pub ts: i64,
2370    /// Operator callsign of the publisher. `None` when the publisher
2371    /// didn't stamp it.
2372    pub callsign: Option<String>,
2373    /// Marker color (consumer-defined encoding — commonly a 32-bit
2374    /// ARGB integer, sign-extended). `None` when default coloring
2375    /// applies.
2376    pub color: Option<i32>,
2377    /// Cell membership (organizational unit within mesh), if scoped.
2378    /// `None` for cell-agnostic markers.
2379    pub cell_id: Option<String>,
2380    /// Soft-delete sentinel. When `true`, the marker is a tombstone
2381    /// — peers sync the deletion (CRDT keeps the entry so concurrent
2382    /// edits resolve consistently) but consumer UIs filter it out
2383    /// of "current markers" views. peat-mesh's fan-out today does
2384    /// NOT propagate `ChangeEvent::Removed` (Slice 2 work), so the
2385    /// soft-delete-sentinel pattern is the only way to communicate
2386    /// deletions across the mesh until that lands. Wire key: `_deleted`
2387    /// (matches the peat-mesh `transport::document_codec` synthesis
2388    /// convention from PR #103).
2389    pub deleted: bool,
2390}
2391
2392// Wire-shape contract for `Option<T>` fields on `NodeInfo`
2393// (Rust-side emit/parse only; downstream consumers in other repos
2394// have their own contracts).
2395//
2396// - **Emit:** `serialize_node_json` and `serialize_nodes_get_json` both render
2397//   `Option::None` as JSON `null` via `serde_json::json!` macro semantics.
2398//   There is no second emit shape from this codec.
2399//
2400// - **Parse:** `parse_node_json` and `parse_node_publish_json` both treat JSON
2401//   `null` AND a missing key the same way — both yield `None`.
2402//   `serde_json::Value` indexing returns `Value::Null` for missing keys, and
2403//   the typed accessors (`as_i64`, `as_str`, …) return `None` on a null
2404//   variant. So receivers don't need to distinguish "absent" from "explicit
2405//   null" — they're equivalent on the read side. Locked in by
2406//   `legacy_json_without_battery_or_heart_parses_with_none` (absent) and
2407//   `battery_and_heart_reject_non_numeric` (explicit null).
2408//
2409// - **Forward-compat:** parsers ignore unknown keys. Any wire shape a
2410//   future-version peer adds passes through unchanged.
2411
2412/// Command status enumeration
2413#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2414pub enum CommandStatus {
2415    /// Command is pending execution
2416    Pending,
2417    /// Command is being executed
2418    Executing,
2419    /// Command completed successfully
2420    Completed,
2421    /// Command failed
2422    Failed,
2423    /// Command was cancelled
2424    Cancelled,
2425}
2426
2427impl CommandStatus {
2428    fn from_str(s: &str) -> Self {
2429        match s.to_uppercase().as_str() {
2430            "PENDING" => Self::Pending,
2431            "EXECUTING" => Self::Executing,
2432            "COMPLETED" => Self::Completed,
2433            "FAILED" => Self::Failed,
2434            "CANCELLED" => Self::Cancelled,
2435            _ => Self::Pending,
2436        }
2437    }
2438
2439    fn as_str(&self) -> &'static str {
2440        match self {
2441            Self::Pending => "PENDING",
2442            Self::Executing => "EXECUTING",
2443            Self::Completed => "COMPLETED",
2444            Self::Failed => "FAILED",
2445            Self::Cancelled => "CANCELLED",
2446        }
2447    }
2448}
2449
2450/// Command information for C2
2451#[derive(Debug, Clone, uniffi::Record)]
2452pub struct CommandInfo {
2453    /// Unique command identifier
2454    pub id: String,
2455    /// Command type (e.g., "TRACK_TARGET", "MOVE", "ABORT")
2456    pub command_type: String,
2457    /// Target cell or node ID
2458    pub target_id: String,
2459    /// Command parameters as JSON string
2460    pub parameters: String,
2461    /// Command priority (1-5, 1 = highest)
2462    pub priority: u8,
2463    /// Command status
2464    pub status: CommandStatus,
2465    /// Originator ID
2466    pub originator: String,
2467    /// Created timestamp (Unix millis)
2468    pub created_at: i64,
2469    /// Last update timestamp (Unix millis)
2470    pub last_update: i64,
2471}
2472
2473// =============================================================================
2474// PeatNode Extensions for Typed Data Access
2475// =============================================================================
2476
2477#[cfg(feature = "sync")]
2478#[uniffi::export]
2479impl PeatNode {
2480    // -------------------------------------------------------------------------
2481    // Cell Operations
2482    // -------------------------------------------------------------------------
2483
2484    /// Get all cells from the sync document
2485    pub fn get_cells(&self) -> Result<Vec<CellInfo>, PeatError> {
2486        self.runtime.block_on(async {
2487            let backend = &self.storage_backend;
2488            let coll = backend.collection(collections::CELLS);
2489
2490            let docs = coll
2491                .scan()
2492                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2493
2494            let mut cells = Vec::new();
2495            for (id, data) in docs {
2496                if let Ok(json) = String::from_utf8(data) {
2497                    if let Ok(cell) = parse_cell_json(&id, &json) {
2498                        cells.push(cell);
2499                    }
2500                }
2501            }
2502            Ok(cells)
2503        })
2504    }
2505
2506    /// Get a specific cell by ID
2507    pub fn get_cell(&self, cell_id: &str) -> Result<Option<CellInfo>, PeatError> {
2508        self.runtime.block_on(async {
2509            let backend = &self.storage_backend;
2510            let coll = backend.collection(collections::CELLS);
2511
2512            match coll.get(cell_id) {
2513                Ok(Some(data)) => {
2514                    let json = String::from_utf8(data).map_err(|e| PeatError::StorageError {
2515                        msg: format!("Invalid UTF-8: {}", e),
2516                    })?;
2517                    let cell = parse_cell_json(cell_id, &json)?;
2518                    Ok(Some(cell))
2519                }
2520                Ok(None) => Ok(None),
2521                Err(e) => Err(PeatError::StorageError { msg: e.to_string() }),
2522            }
2523        })
2524    }
2525
2526    /// Store a cell
2527    pub fn put_cell(&self, cell: CellInfo) -> Result<(), PeatError> {
2528        let json = serialize_cell_json(&cell)?;
2529        self.runtime.block_on(async {
2530            let backend = &self.storage_backend;
2531            let coll = backend.collection(collections::CELLS);
2532            coll.upsert(&cell.id, json.into_bytes())
2533                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2534        })
2535    }
2536
2537    // -------------------------------------------------------------------------
2538    // Track Operations
2539    // -------------------------------------------------------------------------
2540
2541    /// Get all tracks from the sync document.
2542    ///
2543    /// Reads via `peat_mesh::Node::query(...)` so the writer/reader API
2544    /// stays consistent with `ingest_position_via_translator`'s
2545    /// `Node::publish_with_origin` path. The earlier implementation
2546    /// scanned `AutomergeBackend::collection(...).scan()` directly,
2547    /// expecting the bytes to be flat JSON of the original body — but
2548    /// `publish_with_origin` writes a Document whose Automerge map
2549    /// shape doesn't match that expectation, so every body field came
2550    /// back at `parse_track_json`'s `unwrap_or` defaults (peat#832).
2551    /// Going through `Node::query` decodes the Document fields
2552    /// properly and the read result matches what the writer published.
2553    /// The `track_tests::ingest_position_via_translator_then_get_tracks_preserves_body`
2554    /// test locks this in.
2555    pub fn get_tracks(&self) -> Result<Vec<TrackInfo>, PeatError> {
2556        use peat_mesh::sync::types::Query;
2557        self.runtime.block_on(async {
2558            let docs = self
2559                .node
2560                .query(collections::TRACKS, &Query::All)
2561                .await
2562                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2563
2564            let mut tracks = Vec::with_capacity(docs.len());
2565            for doc in docs {
2566                if let Some(id) = doc.id.clone() {
2567                    if let Ok(track) = track_from_document(&id, &doc) {
2568                        tracks.push(track);
2569                    }
2570                }
2571            }
2572            Ok(tracks)
2573        })
2574    }
2575
2576    /// Get a specific track by ID. Routes through `Node::get` for the
2577    /// same writer/reader symmetry reason as `get_tracks` (peat#832).
2578    pub fn get_track(&self, track_id: &str) -> Result<Option<TrackInfo>, PeatError> {
2579        self.runtime.block_on(async {
2580            let id = track_id.to_string();
2581            match self.node.get(collections::TRACKS, &id).await {
2582                Ok(Some(doc)) => Ok(Some(track_from_document(track_id, &doc)?)),
2583                Ok(None) => Ok(None),
2584                Err(e) => Err(PeatError::StorageError { msg: e.to_string() }),
2585            }
2586        })
2587    }
2588
2589    /// Store a track. Publishes through `Node::publish` so the
2590    /// resulting Document lives in the same storage namespace
2591    /// `Node::query` / `Node::get` read from — the BLE-bridged
2592    /// `ingest_position_via_translator` path already publishes this
2593    /// way, so unifying the typed `put_track` path keeps writer/reader
2594    /// symmetric for both publish surfaces (peat#832).
2595    ///
2596    /// Behavioral change vs pre-#836: this now fires through
2597    /// `TransportManager` fan-out (the `Node::publish` path emits a
2598    /// `ChangeEvent` that BLE / iroh transport drains observe), where
2599    /// the pre-fix `coll.upsert(json_bytes)` only emitted the
2600    /// in-process observer broadcast. No production caller exists
2601    /// today (production tracks come in via `ingestPositionJni`), so
2602    /// the change is observable only via UniFFI Kotlin / Swift
2603    /// consumers if any appear later. Documented here so the next
2604    /// reader doesn't have to re-trace the change to find out.
2605    pub fn put_track(&self, track: TrackInfo) -> Result<(), PeatError> {
2606        let doc = track_to_document(&track)?;
2607        self.runtime.block_on(async {
2608            self.node
2609                .publish(collections::TRACKS, doc)
2610                .await
2611                .map(|_id| ())
2612                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2613        })
2614    }
2615
2616    // -------------------------------------------------------------------------
2617    // Node Operations
2618    // -------------------------------------------------------------------------
2619
2620    /// Get all nodes from the sync document
2621    pub fn get_nodes(&self) -> Result<Vec<NodeInfo>, PeatError> {
2622        self.runtime.block_on(async {
2623            let backend = &self.storage_backend;
2624            let coll = backend.collection(collections::NODES);
2625
2626            let docs = coll
2627                .scan()
2628                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2629
2630            let mut nodes = Vec::new();
2631            for (id, data) in docs {
2632                if let Ok(json) = String::from_utf8(data) {
2633                    if let Ok(node) = parse_node_json(&id, &json) {
2634                        nodes.push(node);
2635                    }
2636                }
2637            }
2638            Ok(nodes)
2639        })
2640    }
2641
2642    /// Store a node
2643    pub fn put_node(&self, node: NodeInfo) -> Result<(), PeatError> {
2644        let json = serialize_node_json(&node)?;
2645        self.runtime.block_on(async {
2646            let backend = &self.storage_backend;
2647            let coll = backend.collection(collections::NODES);
2648            coll.upsert(&node.id, json.into_bytes())
2649                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2650        })
2651    }
2652
2653    // -------------------------------------------------------------------------
2654    // Marker Operations (operator-placed map pins, synced via ADR-035
2655    // Universal Document transport)
2656    // -------------------------------------------------------------------------
2657
2658    /// Get all markers from the sync document.
2659    ///
2660    /// Returns the canonical typed list of operator-placed pins
2661    /// across the mesh. Origin-agnostic — locally-created and
2662    /// peer-synced markers are indistinguishable in the result.
2663    /// Plugin consumers (PeatMapComponent's periodic refresh, the
2664    /// Peat Markers panel readout) call this and render every entry
2665    /// with the same code path.
2666    pub fn get_markers(&self) -> Result<Vec<MarkerInfo>, PeatError> {
2667        self.runtime.block_on(async {
2668            let backend = &self.storage_backend;
2669            let coll = backend.collection(collections::MARKERS);
2670
2671            let docs = coll
2672                .scan()
2673                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2674
2675            let mut markers = Vec::new();
2676            for (id, data) in docs {
2677                let json_str = String::from_utf8_lossy(&data);
2678                match parse_marker_publish_json(&id, &json_str) {
2679                    Ok(m) => markers.push(m),
2680                    Err(_) => {
2681                        // Malformed entry — skip silently. Same shape
2682                        // as get_nodes / get_commands handle parse
2683                        // errors: don't poison the whole list with one
2684                        // bad doc.
2685                    }
2686                }
2687            }
2688            Ok(markers)
2689        })
2690    }
2691
2692    /// Store a marker.
2693    ///
2694    /// Persists into the `markers` collection. peat-mesh's fan-out
2695    /// observes the change and routes via the registered transports
2696    /// (universal-Document path on BLE via LiteBridgeTranslator,
2697    /// iroh sync for cross-mesh peers). Receivers see the same
2698    /// `MarkerInfo` shape on their side.
2699    pub fn put_marker(&self, marker: MarkerInfo) -> Result<(), PeatError> {
2700        let json = serialize_marker_json(&marker)?;
2701        let uid = marker.uid.clone();
2702        self.runtime.block_on(async {
2703            let backend = &self.storage_backend;
2704            let coll = backend.collection(collections::MARKERS);
2705            coll.upsert(&uid, json.into_bytes())
2706                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2707        })
2708    }
2709
2710    // -------------------------------------------------------------------------
2711    // Command Operations (C2)
2712    // -------------------------------------------------------------------------
2713
2714    /// Get all pending commands
2715    pub fn get_commands(&self) -> Result<Vec<CommandInfo>, PeatError> {
2716        self.runtime.block_on(async {
2717            let backend = &self.storage_backend;
2718            let coll = backend.collection(collections::COMMANDS);
2719
2720            let docs = coll
2721                .scan()
2722                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2723
2724            let mut commands = Vec::new();
2725            for (id, data) in docs {
2726                if let Ok(json) = String::from_utf8(data) {
2727                    if let Ok(cmd) = parse_command_json(&id, &json) {
2728                        commands.push(cmd);
2729                    }
2730                }
2731            }
2732            Ok(commands)
2733        })
2734    }
2735
2736    /// Store a command (for C2 issuance)
2737    pub fn put_command(&self, command: CommandInfo) -> Result<(), PeatError> {
2738        let json = serialize_command_json(&command)?;
2739        self.runtime.block_on(async {
2740            let backend = &self.storage_backend;
2741            let coll = backend.collection(collections::COMMANDS);
2742            coll.upsert(&command.id, json.into_bytes())
2743                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2744        })
2745    }
2746}
2747
2748// =============================================================================
2749// Blob Transfer (ADR-060) — not UniFFI-exported; reached via direct JNI only
2750// =============================================================================
2751
2752#[cfg(feature = "sync")]
2753impl PeatNode {
2754    /// Enable the parallel blob-transfer endpoint.
2755    ///
2756    /// Constructs a `NetworkedIrohBlobStore` on the tokio runtime owned by
2757    /// this node and stores it for later use via `blob_put` / `blob_get`.
2758    /// Bind address defaults to `0.0.0.0:0` (ephemeral) when None.
2759    pub fn enable_blob_transfer(
2760        &self,
2761        bind_addr: Option<std::net::SocketAddr>,
2762    ) -> Result<(), PeatError> {
2763        let blob_dir = self.storage_path.join("blobs");
2764        std::fs::create_dir_all(&blob_dir).map_err(|e| PeatError::StorageError {
2765            msg: format!("Failed to create blob dir {:?}: {}", blob_dir, e),
2766        })?;
2767
2768        let config = PeatMeshIrohConfig {
2769            bind_addr,
2770            ..Default::default()
2771        };
2772
2773        let store = self
2774            .runtime
2775            .block_on(NetworkedIrohBlobStore::from_config(blob_dir, &config))
2776            .map_err(|e| PeatError::SyncError {
2777                msg: format!("Failed to create blob store: {}", e),
2778            })?;
2779
2780        #[cfg(target_os = "android")]
2781        android_log(&format!(
2782            "Blob transfer enabled. EndpointId={}",
2783            store.endpoint_id().fmt_short()
2784        ));
2785
2786        let mut slot = self.blob_store.write().map_err(|_| PeatError::SyncError {
2787            msg: "blob_store lock poisoned".to_string(),
2788        })?;
2789        *slot = Some(store);
2790        Ok(())
2791    }
2792
2793    /// Add a known blob peer by hex EndpointId and socket address.
2794    /// Uses peat-mesh's `add_peer_from_hex` so no iroh types cross into
2795    /// peat-ffi.
2796    pub fn blob_add_peer(&self, peer_id_hex: &str, address: &str) -> Result<(), PeatError> {
2797        let store_guard = self.blob_store.read().map_err(|_| PeatError::SyncError {
2798            msg: "blob_store lock poisoned".to_string(),
2799        })?;
2800        let store = store_guard.as_ref().ok_or(PeatError::SyncError {
2801            msg: "blob transfer not enabled".to_string(),
2802        })?;
2803
2804        let store_clone = Arc::clone(store);
2805        let hex = peer_id_hex.to_string();
2806        let addr = address.to_string();
2807        self.runtime
2808            .block_on(async move { store_clone.add_peer_from_hex(&hex, &addr).await })
2809            .map_err(|e| PeatError::SyncError {
2810                msg: format!("blob_add_peer: {}", e),
2811            })?;
2812
2813        #[cfg(target_os = "android")]
2814        android_log(&format!(
2815            "Blob peer added: {} at {}",
2816            &peer_id_hex[..16.min(peer_id_hex.len())],
2817            address
2818        ));
2819
2820        Ok(())
2821    }
2822
2823    /// Store bytes in the local blob store. Returns the content hash as hex.
2824    pub fn blob_put(&self, data: &[u8], content_type: &str) -> Result<String, PeatError> {
2825        let store_guard = self.blob_store.read().map_err(|_| PeatError::SyncError {
2826            msg: "blob_store lock poisoned".to_string(),
2827        })?;
2828        let store = store_guard.as_ref().ok_or(PeatError::SyncError {
2829            msg: "blob transfer not enabled".to_string(),
2830        })?;
2831
2832        let metadata = BlobMetadata {
2833            content_type: Some(content_type.to_string()),
2834            name: None,
2835            custom: Default::default(),
2836        };
2837
2838        let store_clone = Arc::clone(store);
2839        let data_vec = data.to_vec();
2840        let token = self
2841            .runtime
2842            .block_on(async move {
2843                store_clone
2844                    .create_blob_from_bytes(&data_vec, metadata)
2845                    .await
2846            })
2847            .map_err(|e| PeatError::StorageError {
2848                msg: format!("blob put failed: {}", e),
2849            })?;
2850
2851        Ok(token.hash.as_hex().to_string())
2852    }
2853
2854    /// Fetch blob bytes by content hash (hex). Tries local first, then
2855    /// known peers. Returns the bytes or an error.
2856    pub fn blob_get(&self, hash_hex: &str) -> Result<Vec<u8>, PeatError> {
2857        let store_guard = self.blob_store.read().map_err(|_| PeatError::SyncError {
2858            msg: "blob_store lock poisoned".to_string(),
2859        })?;
2860        let store = store_guard.as_ref().ok_or(PeatError::SyncError {
2861            msg: "blob transfer not enabled".to_string(),
2862        })?;
2863
2864        let token = BlobToken {
2865            hash: peat_mesh::storage::BlobHash(hash_hex.to_string()),
2866            size_bytes: 0, // unknown; fetch_blob doesn't use this for lookup
2867            metadata: BlobMetadata {
2868                content_type: None,
2869                name: None,
2870                custom: Default::default(),
2871            },
2872        };
2873
2874        let store_clone = Arc::clone(store);
2875        let handle = self
2876            .runtime
2877            .block_on(async move { store_clone.fetch_blob_simple(&token).await })
2878            .map_err(|e| PeatError::StorageError {
2879                msg: format!("blob fetch failed: {}", e),
2880            })?;
2881
2882        std::fs::read(&handle.path).map_err(|e| PeatError::StorageError {
2883            msg: format!("blob read failed: {}", e),
2884        })
2885    }
2886
2887    /// Check if a blob exists locally without network fetch.
2888    pub fn blob_exists_locally(&self, hash_hex: &str) -> bool {
2889        let store_guard = match self.blob_store.read() {
2890            Ok(g) => g,
2891            Err(_) => return false,
2892        };
2893        let store = match store_guard.as_ref() {
2894            Some(s) => s,
2895            None => return false,
2896        };
2897        let hash = peat_mesh::storage::BlobHash(hash_hex.to_string());
2898        store.blob_exists_locally(&hash)
2899    }
2900
2901    /// Get the blob endpoint ID as hex (returns None if blob transfer is
2902    /// disabled).
2903    pub fn blob_endpoint_id(&self) -> Option<String> {
2904        let store_guard = self.blob_store.read().ok()?;
2905        let store = store_guard.as_ref()?;
2906        Some(hex::encode(store.endpoint_id().as_bytes()))
2907    }
2908
2909    /// Get the blob endpoint's bound socket address as "ip:port".
2910    /// Useful for configuring remote peers and for tests.
2911    pub fn blob_bound_addr(&self) -> Option<String> {
2912        let store_guard = self.blob_store.read().ok()?;
2913        let store = store_guard.as_ref()?;
2914        store.bound_addr_string()
2915    }
2916}
2917
2918// =============================================================================
2919// JSON Serialization Helpers
2920// =============================================================================
2921
2922fn parse_cell_json(id: &str, json: &str) -> Result<CellInfo, PeatError> {
2923    let root: serde_json::Value =
2924        serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
2925            msg: format!("Invalid JSON: {}", e),
2926        })?;
2927    // Docs published through the node layer are wrapped as {id, fields:{..}};
2928    // flat (legacy) writes keep fields at the root. Read from `fields` if present.
2929    let v = match root.get("fields") {
2930        Some(f) if f.is_object() => f,
2931        _ => &root,
2932    };
2933
2934    Ok(CellInfo {
2935        id: id.to_string(),
2936        name: v["name"].as_str().unwrap_or(id).to_string(),
2937        status: CellStatus::from_str(v["status"].as_str().unwrap_or("OFFLINE")),
2938        node_count: v["node_count"].as_u64().unwrap_or(0) as u32,
2939        center_lat: v["center_lat"].as_f64().unwrap_or(0.0),
2940        center_lon: v["center_lon"].as_f64().unwrap_or(0.0),
2941        capabilities: v["capabilities"]
2942            .as_array()
2943            .map(|arr| {
2944                arr.iter()
2945                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
2946                    .collect()
2947            })
2948            .unwrap_or_default(),
2949        formation_id: v["formation_id"].as_str().map(|s| s.to_string()),
2950        leader_id: v["leader_id"].as_str().map(|s| s.to_string()),
2951        last_update: v["last_update"].as_i64().unwrap_or(0),
2952        scenario_command: v["scenario_command"].as_str().map(|s| s.to_string()),
2953    })
2954}
2955
2956fn serialize_cell_json(cell: &CellInfo) -> Result<String, PeatError> {
2957    let v = serde_json::json!({
2958        "name": cell.name,
2959        "status": cell.status.as_str(),
2960        "node_count": cell.node_count,
2961        "center_lat": cell.center_lat,
2962        "center_lon": cell.center_lon,
2963        "capabilities": cell.capabilities,
2964        "formation_id": cell.formation_id,
2965        "leader_id": cell.leader_id,
2966        "last_update": cell.last_update,
2967        "scenario_command": cell.scenario_command,
2968    });
2969    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
2970}
2971
2972/// Adapt a `TrackInfo` into a `peat_mesh::Document` for publishing.
2973///
2974/// Routes through the existing `serialize_track_json` so the body-field
2975/// encoding rules stay in one place — re-deserializing the JSON into a
2976/// `Map<String, Value>` and stuffing into `Document.fields` is the same
2977/// shape `peat_protocol::sync::ble_translation::value_to_mesh_document`
2978/// produces from the translator path. One extra serde round-trip per
2979/// `put_track`; acceptable for the consumer counts the plugin handles.
2980fn track_to_document(track: &TrackInfo) -> Result<peat_mesh::sync::types::Document, PeatError> {
2981    let json = serialize_track_json(track)?;
2982    let value: serde_json::Value =
2983        serde_json::from_str(&json).map_err(|e| PeatError::EncodingError {
2984            msg: format!("track_to_document: re-parse failed: {}", e),
2985        })?;
2986    let fields: std::collections::HashMap<String, serde_json::Value> = match value {
2987        serde_json::Value::Object(map) => map.into_iter().collect(),
2988        _ => std::collections::HashMap::new(),
2989    };
2990    Ok(peat_mesh::sync::types::Document {
2991        id: Some(track.id.clone()),
2992        fields,
2993        updated_at: std::time::SystemTime::now(),
2994    })
2995}
2996
2997/// Adapt a `peat_mesh::Document` into a `TrackInfo`.
2998///
2999/// Routes through the existing `parse_track_json` so the body-field
3000/// mapping rules stay in one place — `Document.fields` is a flat
3001/// `HashMap<String, Value>`, so re-emitting them as a JSON object is
3002/// a one-step adapter rather than a full reimplementation. The cost
3003/// is one extra serde_json round-trip per track on read; acceptable
3004/// for the consumer counts the plugin handles (single-digit
3005/// nodes × tens of tracks).
3006fn track_from_document(
3007    id: &str,
3008    doc: &peat_mesh::sync::types::Document,
3009) -> Result<TrackInfo, PeatError> {
3010    let body: serde_json::Map<String, serde_json::Value> = doc
3011        .fields
3012        .iter()
3013        .map(|(k, v)| (k.clone(), v.clone()))
3014        .collect();
3015    let json = serde_json::to_string(&serde_json::Value::Object(body))
3016        .map_err(|e| PeatError::EncodingError { msg: e.to_string() })?;
3017    parse_track_json(id, &json)
3018}
3019
3020fn parse_track_json(id: &str, json: &str) -> Result<TrackInfo, PeatError> {
3021    let v: serde_json::Value = serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
3022        msg: format!("Invalid JSON: {}", e),
3023    })?;
3024
3025    Ok(TrackInfo {
3026        id: id.to_string(),
3027        source_node: v["source_node"].as_str().unwrap_or("unknown").to_string(),
3028        cell_id: v["cell_id"].as_str().map(|s| s.to_string()),
3029        formation_id: v["formation_id"].as_str().map(|s| s.to_string()),
3030        lat: v["lat"].as_f64().unwrap_or(0.0),
3031        lon: v["lon"].as_f64().unwrap_or(0.0),
3032        hae: v["hae"].as_f64(),
3033        cep: v["cep"].as_f64(),
3034        heading: v["heading"].as_f64(),
3035        speed: v["speed"].as_f64(),
3036        classification: v["classification"].as_str().unwrap_or("a-u-G").to_string(),
3037        confidence: v["confidence"].as_f64().unwrap_or(0.5),
3038        category: TrackCategory::from_str(v["category"].as_str().unwrap_or("UNKNOWN")),
3039        created_at: v["created_at"].as_i64().unwrap_or(0),
3040        last_update: v["last_update"].as_i64().unwrap_or(0),
3041        attributes: v["attributes"]
3042            .as_object()
3043            .map(|obj| {
3044                obj.iter()
3045                    .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
3046                    .collect()
3047            })
3048            .unwrap_or_default(),
3049    })
3050}
3051
3052fn serialize_track_json(track: &TrackInfo) -> Result<String, PeatError> {
3053    let v = serde_json::json!({
3054        "source_node": track.source_node,
3055        "cell_id": track.cell_id,
3056        "formation_id": track.formation_id,
3057        "lat": track.lat,
3058        "lon": track.lon,
3059        "hae": track.hae,
3060        "cep": track.cep,
3061        "heading": track.heading,
3062        "speed": track.speed,
3063        "classification": track.classification,
3064        "confidence": track.confidence,
3065        "category": track.category.as_str(),
3066        "created_at": track.created_at,
3067        "last_update": track.last_update,
3068        "attributes": track.attributes,
3069    });
3070    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3071}
3072
3073fn parse_node_json(id: &str, json: &str) -> Result<NodeInfo, PeatError> {
3074    let root: serde_json::Value =
3075        serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
3076            msg: format!("Invalid JSON: {}", e),
3077        })?;
3078
3079    // Node docs published through the node layer are wrapped as
3080    // `{id, fields:{..}, updated_at}`; flat (legacy storage_backend) writes
3081    // keep the fields at the root. Read from `fields` when it's an object.
3082    let v = match root.get("fields") {
3083        Some(f) if f.is_object() => f,
3084        _ => &root,
3085    };
3086
3087    Ok(NodeInfo {
3088        id: id.to_string(),
3089        node_type: v["node_type"].as_str().unwrap_or("unknown").to_string(),
3090        name: v["name"].as_str().unwrap_or(id).to_string(),
3091        status: NodeStatus::from_str(v["status"].as_str().unwrap_or("OFFLINE")),
3092        lat: v["lat"].as_f64().unwrap_or(0.0),
3093        lon: v["lon"].as_f64().unwrap_or(0.0),
3094        hae: v["hae"].as_f64(),
3095        readiness: v["readiness"].as_f64().unwrap_or(0.0),
3096        capabilities: v["capabilities"]
3097            .as_array()
3098            .map(|arr| {
3099                arr.iter()
3100                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
3101                    .collect()
3102            })
3103            .unwrap_or_default(),
3104        cell_id: v["cell_id"].as_str().map(|s| s.to_string()),
3105        battery_percent: parse_battery_percent(&v["battery_percent"]),
3106        heart_rate: parse_heart_rate(&v["heart_rate"]),
3107        last_heartbeat: v["last_heartbeat"].as_i64().unwrap_or(0),
3108    })
3109}
3110
3111/// Parse a Kotlin-side `publishNodeJni` payload into a
3112/// `NodeInfo`.
3113///
3114/// Distinct from `parse_node_json` because the JNI publish path
3115/// supplies a few different defaults: `node_type` defaults to
3116/// `"SOLDIER"` here vs `"unknown"` in the storage parser; `status`
3117/// defaults to `"ACTIVE"` here vs `"OFFLINE"` for storage; `readiness`
3118/// defaults to `1.0` here vs `0.0`. The `last_heartbeat` field is
3119/// honored from the wire when present (with a `now() + 60s` clock-skew
3120/// clamp via `parse_publish_last_heartbeat`); falls back to local
3121/// `Utc::now()` only when the publisher omits it. See
3122/// [`parse_publish_last_heartbeat`] for the full semantics.
3123///
3124/// Centralizing this in a free function makes it directly
3125/// unit-testable and means the inline JNI path and the test suite
3126/// share the exact codec implementation — the duplication that hid
3127/// peat#835.
3128///
3129/// Errors:
3130/// - `InvalidInput` if the JSON is malformed or `id` is missing/empty (consumed
3131///   as the storage key downstream; an empty id would collide with
3132///   `getNodesJni`'s scan results).
3133fn parse_node_publish_json(json_str: &str) -> Result<NodeInfo, PeatError> {
3134    let v: serde_json::Value =
3135        serde_json::from_str(json_str).map_err(|e| PeatError::InvalidInput {
3136            msg: format!("publishNode: invalid JSON: {}", e),
3137        })?;
3138
3139    let id = match v["id"].as_str() {
3140        Some(id) if !id.is_empty() => id.to_string(),
3141        _ => {
3142            return Err(PeatError::InvalidInput {
3143                msg: "publishNode: missing or empty 'id' field".to_string(),
3144            });
3145        }
3146    };
3147
3148    Ok(NodeInfo {
3149        id,
3150        node_type: v["node_type"].as_str().unwrap_or("SOLDIER").to_string(),
3151        name: v["name"].as_str().unwrap_or("Unknown").to_string(),
3152        status: NodeStatus::from_str(v["status"].as_str().unwrap_or("ACTIVE")),
3153        lat: v["lat"].as_f64().unwrap_or(0.0),
3154        lon: v["lon"].as_f64().unwrap_or(0.0),
3155        hae: v["hae"].as_f64(),
3156        readiness: v["readiness"].as_f64().unwrap_or(1.0),
3157        capabilities: v["capabilities"]
3158            .as_array()
3159            .map(|arr| {
3160                arr.iter()
3161                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
3162                    .collect()
3163            })
3164            .unwrap_or_else(|| vec!["PLI".to_string()]),
3165        cell_id: v["cell_id"].as_str().map(|s| s.to_string()),
3166        battery_percent: parse_battery_percent(&v["battery_percent"]),
3167        heart_rate: parse_heart_rate(&v["heart_rate"]),
3168        last_heartbeat: parse_publish_last_heartbeat(&v["last_heartbeat"]),
3169    })
3170}
3171
3172/// Parse the `last_heartbeat` field on a publish-side JSON envelope.
3173///
3174/// Three intents we must honor faithfully:
3175/// 1. **Wire absent → stamp `now()`.** Real publishers (Kotlin self-PLI,
3176///    BLE-bridged peripheral relay) don't carry a timestamp; the JNI surface
3177///    always meant "this publish is fresh."
3178/// 2. **Wire `0` → preserve `0`.** Per `NodeInfo`'s field doc, `last_heartbeat
3179///    = 0` is the documented stale-record sentinel ("1970-01-01 stale"). The
3180///    earlier `> 0` filter silently overrode this — a publisher sending the
3181///    documented stale marker got `Utc::now()` back, the *opposite* signal.
3182///    That was a writer/reader-asymmetry regression of the same class peat#835
3183///    was opened to fix; round-4 drops the filter.
3184/// 3. **Wire absurdly far in the future → clamp to `now()`.** A peer with a
3185///    future-skewed clock can publish `i64::MAX` or any timestamp ahead of
3186///    local time; downstream Kotlin staleness UI consumes the value raw via
3187///    `getStalenessString` and would show the node as "always fresh." Cap
3188///    acceptance at `now() + 60_000ms` (60 s grace for legitimate clock drift
3189///    in distributed systems); beyond that, treat as adversarial /
3190///    misconfigured and stamp local `now()`.
3191///
3192/// 4. **Wire negative → collapse to the stale-marker (`0`).** Round-4 let
3193///    negatives pass through with a doc-comment claiming downstream time-delta
3194///    arithmetic still produced a sensible age; that's wrong: `now - i64::MIN`
3195///    overflows i64, and Kotlin `Long` subtraction silently wraps, producing
3196///    nonsense staleness output (or panic in Rust debug builds). Negative
3197///    timestamps are pathological — pre-epoch publish makes no sense in this
3198///    product — and collapsing them onto the documented stale-marker (`0`)
3199///    keeps the UI's arithmetic safe while preserving the "very stale" intent.
3200fn parse_publish_last_heartbeat(v: &serde_json::Value) -> i64 {
3201    let now_ms = chrono::Utc::now().timestamp_millis();
3202    // 60 s grace covers normal NTP drift between mobile devices on
3203    // unrelated networks; beyond that, the value is broken.
3204    const FUTURE_GRACE_MS: i64 = 60_000;
3205    let max_acceptable = now_ms.saturating_add(FUTURE_GRACE_MS);
3206    match v.as_i64() {
3207        Some(n) if n > max_acceptable => now_ms,
3208        // Collapse negatives to the documented stale-marker — both
3209        // bound the downstream Long-subtraction and preserve the
3210        // publisher's "very stale" intent unambiguously.
3211        Some(n) if n < 0 => 0,
3212        Some(n) => n,
3213        None => now_ms,
3214    }
3215}
3216
3217/// Serialize a slice of `NodeInfo` into the JSON-array shape
3218/// `getNodesJni` returns to Kotlin.
3219///
3220/// Mirror of [`parse_node_publish_json`] for the read-back path.
3221/// Pre-round-3 this was inlined inside the JNI function — that's the
3222/// duplicated-codec class peat#835 was opened to lock; extracting it
3223/// here makes the emit-side schema directly testable and keeps
3224/// writer/reader symmetry single-sourced.
3225///
3226/// Falls through to `"[]"` on serializer failure (the JNI surface
3227/// returned the same string on `get_nodes` errors before the
3228/// extraction; preserving that for back-compat).
3229///
3230/// Not gated on `feature = "sync"` even though the only caller
3231/// (`getNodesJni`) is — the body operates on `NodeInfo` and
3232/// `serde_json` only, and the mirror parser `serialize_node_json`
3233/// is unconditional. Asymmetric gating between the pair would be
3234/// confusing to maintainers and `cargo check --no-default-features`
3235/// wouldn't catch the inconsistency.
3236fn serialize_nodes_get_json(nodes: &[NodeInfo]) -> String {
3237    let json_array: Vec<serde_json::Value> = nodes
3238        .iter()
3239        .map(|p| {
3240            serde_json::json!({
3241                "id": p.id,
3242                "node_type": p.node_type,
3243                "name": p.name,
3244                "status": p.status.as_str(),
3245                "lat": p.lat,
3246                "lon": p.lon,
3247                "hae": p.hae,
3248                "readiness": p.readiness,
3249                "capabilities": p.capabilities,
3250                "cell_id": p.cell_id,
3251                "battery_percent": p.battery_percent,
3252                "heart_rate": p.heart_rate,
3253                "last_heartbeat": p.last_heartbeat,
3254            })
3255        })
3256        .collect();
3257    serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
3258}
3259
3260/// Coerce a JSON `Value` into a numeric value as i64.
3261///
3262/// Accepts both integer (`85`) and float (`85.0`, `85.5`) JSON
3263/// numbers; floats round half-away-from-zero per `f64::round()`.
3264/// Returns `None` for any other variant (string, null, array, object,
3265/// missing key).
3266///
3267/// Why both forms: serde_json maps JSON numbers into one of three
3268/// internal representations (i64 / u64 / f64), and `Value::as_i64`
3269/// only matches the first. A Kotlin publisher serializing
3270/// `Int.toDouble().toString()` (i.e. `"85.0"` reaches the parser as
3271/// the float variant), or any node whose JSON serializer renders
3272/// integers with a trailing `.0`, would silently drop the field
3273/// through the int-only path. That's the **same data-loss bug class
3274/// peat#835 was opened to lock**: a publisher writes a value and the
3275/// receiver decodes `None`, indistinguishable from "no sensor."
3276/// Empirically `serde_json::json!(85.0).as_i64() == None`; the float
3277/// fallback closes the gap.
3278///
3279/// **Precision contract — important for callers reusing this helper
3280/// outside of `parse_battery_percent` / `parse_heart_rate`**:
3281///
3282/// JSON Numbers above `i64::MAX` (i.e. stored as `u64` in serde_json,
3283/// 9.22e18..1.84e19) are unreachable by `as_i64()` and traverse the
3284/// `as_f64()` fallback. f64 has only 53 bits of mantissa, so values
3285/// above 2⁵³ (≈ 9.0e15) lose integer precision via that path —
3286/// e.g. `9_007_199_254_740_993_u64` round-trips through f64 as
3287/// `9_007_199_254_740_992`.
3288///
3289/// For `battery_percent` (0..=100) and `heart_rate` (0..=250) this is
3290/// inconsequential: the subsequent `clamp` truncates any
3291/// astronomically-large value to the same range end. Callers operating
3292/// on a wider range or needing exact integer fidelity above 2⁵³ should
3293/// pre-validate the wire shape (e.g. reject non-i64 Numbers explicitly)
3294/// rather than reuse this helper.
3295///
3296/// **Rounding mode**: `f64::round()` rounds half-away-from-zero
3297/// (`85.5 → 86`, `-85.5 → -86`). If a future caller depends on
3298/// banker's-rounding or half-to-even semantics, switch to
3299/// `f.round_ties_even()` (Rust 1.77+) and update tests accordingly.
3300fn coerce_json_number_to_i64(v: &serde_json::Value) -> Option<i64> {
3301    if let Some(n) = v.as_i64() {
3302        return Some(n);
3303    }
3304    // `f64::round() as i64` is saturating in current Rust (1.45+):
3305    // `f64::INFINITY as i64 == i64::MAX`, NaN as i64 == 0. Both
3306    // outcomes get clamped by the caller into the logical range, so
3307    // pathological floats fail-safe rather than panic.
3308    v.as_f64().map(|f| f.round() as i64)
3309}
3310
3311/// Parse a JSON `Value` into a battery percentage, clamping into the
3312/// physical 0..=100 range.
3313///
3314/// - Accepts integer or float JSON numbers (`85`, `85.0`, `85.5` → `85`). See
3315///   [`coerce_json_number_to_i64`] for why both forms.
3316/// - Numeric values clamp on out-of-range. The silent-`None`-on- overflow shape
3317///   `as_i64().and_then(|n| i32::try_from(n).ok())` produced was the same bug
3318///   class peat#835 was opened to prevent: a pathological 2³² `battery_percent`
3319///   becomes "no battery sensor," visually identical to the legitimate `None`
3320///   case. Clamp fails-safe to 0 or 100 instead.
3321/// - Non-numeric (string, object, missing key, JSON null) returns `None`. We
3322///   accept "no battery sensor" but reject silent type coercion — a `"85"`
3323///   *string* wire payload is a publisher bug, not a value to interpret.
3324///
3325/// Wire form: number in 0–100 (integer or float), or `null` / absent
3326/// for "unknown."
3327fn parse_battery_percent(v: &serde_json::Value) -> Option<i32> {
3328    let n = coerce_json_number_to_i64(v)?;
3329    Some(n.clamp(0, 100) as i32)
3330}
3331
3332/// Parse a JSON `Value` into a heart rate (BPM), clamping into the
3333/// 0..=250 range.
3334///
3335/// - Accepts integer or float JSON numbers; floats round.
3336/// - Lower bound is **0**, not 30: athletic resting bradycardia can dip into
3337///   the 20s, and a sensor reporting 0/asystole is a real emergency signal that
3338///   the UI should surface, not silently round up. The earlier 30 floor masked
3339///   these. Upper bound stays 250 (well above maximal exertion ~220−age) to
3340///   catch overflow payloads.
3341/// - Non-numeric returns `None` ("no wearable sensor present").
3342///
3343/// Wire form: number in 0–250 (integer or float), or `null` / absent
3344/// for "unknown."
3345fn parse_heart_rate(v: &serde_json::Value) -> Option<i32> {
3346    let n = coerce_json_number_to_i64(v)?;
3347    Some(n.clamp(0, 250) as i32)
3348}
3349
3350/// Parse a `MarkerInfo` from the wire JSON (publish-side), with
3351/// graceful field absence: missing optional fields → `None`, missing
3352/// required geo (`uid`/`type`/`lat`/`lon`) → `InvalidInput`.
3353///
3354/// The parser is wire-compatible with the JSON the prior raw-JSON
3355/// publish path produced — see the field comments on `MarkerInfo`
3356/// for key-by-key parity. The `id` argument lets the scan-side
3357/// caller supply the doc id (the doc store's key) when it's not in
3358/// the body; we accept either source as the `uid`.
3359fn parse_marker_publish_json(id: &str, json_str: &str) -> Result<MarkerInfo, PeatError> {
3360    let v: serde_json::Value =
3361        serde_json::from_str(json_str).map_err(|e| PeatError::InvalidInput {
3362            msg: format!("marker JSON: {}", e),
3363        })?;
3364
3365    let uid = v["uid"]
3366        .as_str()
3367        .map(|s| s.to_string())
3368        .filter(|s| !s.is_empty())
3369        .unwrap_or_else(|| id.to_string());
3370    if uid.is_empty() {
3371        return Err(PeatError::InvalidInput {
3372            msg: "marker missing uid (and no doc-store id supplied)".to_string(),
3373        });
3374    }
3375
3376    // Deletion-sentinel detection. A tombstone marker is just
3377    // `{uid, _deleted: true}` — type/lat/lon optional. Receivers
3378    // know to filter the entry out of "current markers" views. We
3379    // need the deletion to ride the same wire envelope as a normal
3380    // marker (peat-mesh fan-out doesn't propagate Removed events
3381    // today), so the doc-store retains the tombstone for CRDT
3382    // consistency.
3383    let deleted = v["_deleted"].as_bool().unwrap_or(false);
3384
3385    let marker_type = if deleted {
3386        v["type"]
3387            .as_str()
3388            .unwrap_or(TOMBSTONE_PLACEHOLDER_TYPE)
3389            .to_string()
3390    } else {
3391        v["type"]
3392            .as_str()
3393            .ok_or_else(|| PeatError::InvalidInput {
3394                msg: format!("marker {uid} missing CoT type"),
3395            })?
3396            .to_string()
3397    };
3398    let lat = if deleted {
3399        v["lat"].as_f64().unwrap_or(0.0)
3400    } else {
3401        v["lat"].as_f64().ok_or_else(|| PeatError::InvalidInput {
3402            msg: format!("marker {uid} missing lat"),
3403        })?
3404    };
3405    let lon = if deleted {
3406        v["lon"].as_f64().unwrap_or(0.0)
3407    } else {
3408        v["lon"].as_f64().ok_or_else(|| PeatError::InvalidInput {
3409            msg: format!("marker {uid} missing lon"),
3410        })?
3411    };
3412    let hae = v["hae"].as_f64();
3413    let ts = v["ts"].as_i64().unwrap_or(0);
3414    let callsign = v["callsign"]
3415        .as_str()
3416        .filter(|s| !s.is_empty())
3417        .map(|s| s.to_string());
3418    let color = coerce_json_number_to_i64(&v["color"]).map(|n| n as i32);
3419    let cell_id = v["cell_id"]
3420        .as_str()
3421        .filter(|s| !s.is_empty())
3422        .map(|s| s.to_string());
3423
3424    Ok(MarkerInfo {
3425        uid,
3426        marker_type,
3427        lat,
3428        lon,
3429        hae,
3430        ts,
3431        callsign,
3432        color,
3433        cell_id,
3434        deleted,
3435    })
3436}
3437
3438/// Serialize the typed list to the JSON shape `getMarkersJni`
3439/// returns. Wire-key parity with `serialize_marker_json` so a doc
3440/// round-trips through the get path identically to the put path.
3441fn serialize_markers_get_json(markers: &[MarkerInfo]) -> String {
3442    let json_array: Vec<serde_json::Value> = markers
3443        .iter()
3444        .map(|m| {
3445            let mut obj = serde_json::json!({
3446                "uid": m.uid,
3447                "type": m.marker_type,
3448                "lat": m.lat,
3449                "lon": m.lon,
3450                "hae": m.hae,
3451                "ts": m.ts,
3452                "callsign": m.callsign,
3453                "color": m.color,
3454                "cell_id": m.cell_id,
3455            });
3456            if m.deleted {
3457                obj["_deleted"] = serde_json::Value::Bool(true);
3458            }
3459            obj
3460        })
3461        .collect();
3462    // `serde_json::to_string` on a `Vec<serde_json::Value>` composed
3463    // entirely of primitives, booleans, strings, and JSON objects we
3464    // just constructed is infallible — the failure modes are
3465    // I/O on `to_writer`, non-string map keys, or NaN floats without
3466    // the `arbitrary_precision` feature. None of those can arise
3467    // from this shape, so the unwrap-to-`"[]"` fallback is dead code
3468    // that exists only because the signature returns `String` (not
3469    // `Result<String, _>`) for symmetry with the JNI consumers'
3470    // `Ok("[]")` semantics on storage error. If a future field type
3471    // change introduces a fallible shape (e.g., `f64::NAN` for a
3472    // missing-altitude sentinel), promote this to `Result` and
3473    // surface the error to the caller.
3474    serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
3475}
3476
3477/// Serialize a single marker for `put_marker` storage. Wire-key
3478/// parity with `serialize_markers_get_json` (single object instead
3479/// of array — same key set, same shapes) so a doc written via
3480/// `put_marker` reads identically through `get_markers`.
3481fn serialize_marker_json(marker: &MarkerInfo) -> Result<String, PeatError> {
3482    let mut v = serde_json::json!({
3483        "uid": marker.uid,
3484        "type": marker.marker_type,
3485        "lat": marker.lat,
3486        "lon": marker.lon,
3487        "hae": marker.hae,
3488        "ts": marker.ts,
3489        "callsign": marker.callsign,
3490        "color": marker.color,
3491        "cell_id": marker.cell_id,
3492    });
3493    if marker.deleted {
3494        v["_deleted"] = serde_json::Value::Bool(true);
3495    }
3496    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3497}
3498
3499fn serialize_node_json(node: &NodeInfo) -> Result<String, PeatError> {
3500    let v = serde_json::json!({
3501        "node_type": node.node_type,
3502        "name": node.name,
3503        "status": node.status.as_str(),
3504        "lat": node.lat,
3505        "lon": node.lon,
3506        "hae": node.hae,
3507        "readiness": node.readiness,
3508        "capabilities": node.capabilities,
3509        "cell_id": node.cell_id,
3510        "battery_percent": node.battery_percent,
3511        "heart_rate": node.heart_rate,
3512        "last_heartbeat": node.last_heartbeat,
3513    });
3514    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3515}
3516
3517fn parse_command_json(id: &str, json: &str) -> Result<CommandInfo, PeatError> {
3518    let root: serde_json::Value =
3519        serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
3520            msg: format!("Invalid JSON: {}", e),
3521        })?;
3522    // Docs published through the node layer are wrapped as {id, fields:{..}};
3523    // flat (legacy) writes keep fields at the root. Read from `fields` if present.
3524    let v = match root.get("fields") {
3525        Some(f) if f.is_object() => f,
3526        _ => &root,
3527    };
3528
3529    Ok(CommandInfo {
3530        id: id.to_string(),
3531        command_type: v["command_type"].as_str().unwrap_or("UNKNOWN").to_string(),
3532        target_id: v["target_id"].as_str().unwrap_or("").to_string(),
3533        parameters: v["parameters"].to_string(),
3534        priority: v["priority"].as_u64().unwrap_or(3) as u8,
3535        status: CommandStatus::from_str(v["status"].as_str().unwrap_or("PENDING")),
3536        originator: v["originator"].as_str().unwrap_or("").to_string(),
3537        created_at: v["created_at"].as_i64().unwrap_or(0),
3538        last_update: v["last_update"].as_i64().unwrap_or(0),
3539    })
3540}
3541
3542fn serialize_command_json(command: &CommandInfo) -> Result<String, PeatError> {
3543    // Parse parameters as JSON or use empty object
3544    let params: serde_json::Value =
3545        serde_json::from_str(&command.parameters).unwrap_or(serde_json::json!({}));
3546
3547    let v = serde_json::json!({
3548        "command_type": command.command_type,
3549        "target_id": command.target_id,
3550        "parameters": params,
3551        "priority": command.priority,
3552        "status": command.status.as_str(),
3553        "originator": command.originator,
3554        "created_at": command.created_at,
3555        "last_update": command.last_update,
3556    });
3557    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3558}
3559
3560#[cfg(test)]
3561mod tests {
3562    use super::*;
3563
3564    #[test]
3565    fn test_peat_version() {
3566        let version = peat_version();
3567        assert!(!version.is_empty());
3568        assert!(version.contains('.'));
3569    }
3570
3571    /// `create_node` must honor `TransportConfigFFI.enable_n0_relay` in BOTH
3572    /// postures, proving the runtime relay flag flows the whole stack:
3573    /// peat-ffi `NodeConfig` -> `IrohTransport::from_seed_with_discovery_at_addr`
3574    /// -> `relay_policy_builder` (presets::N0 vs Empty). Both must construct and
3575    /// bind a working endpoint. Binding does not require reaching n0, so this
3576    /// runs offline; the live relay path is covered by peat-mesh's `#[ignore]`d
3577    /// `relay_n0_sync_e2e` and the manual two-device test.
3578    #[cfg(feature = "sync")]
3579    #[test]
3580    fn create_node_honors_enable_n0_relay_in_both_postures() {
3581        fn make(suffix: &str, enable_n0_relay: bool) -> Arc<PeatNode> {
3582            let storage = std::env::temp_dir().join(format!(
3583                "peat-ffi-relay-test-{}-{}",
3584                std::process::id(),
3585                suffix
3586            ));
3587            let _ = std::fs::remove_dir_all(&storage);
3588            let node = create_node(NodeConfig {
3589                app_id: "relay-toggle-ffi-test".to_string(),
3590                shared_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
3591                bind_address: Some("127.0.0.1:0".to_string()),
3592                storage_path: storage.to_string_lossy().into_owned(),
3593                transport: Some(TransportConfigFFI {
3594                    enable_ble: false,
3595                    ble_mesh_id: None,
3596                    ble_power_profile: None,
3597                    transport_preference: None,
3598                    collection_routes_json: None,
3599                    enable_n0_relay,
3600                }),
3601            })
3602            .unwrap_or_else(|e| panic!("create_node (relay={enable_n0_relay}) failed: {e:?}"));
3603            // Endpoint must be bound either way.
3604            assert!(
3605                !node.endpoint_addr().is_empty(),
3606                "bound endpoint must report an address (relay={enable_n0_relay})"
3607            );
3608            node
3609        }
3610
3611        let _local = make("off", false);
3612        let _relayed = make("on", true);
3613    }
3614
3615    #[test]
3616    fn test_encode_track() {
3617        let track = TrackData {
3618            track_id: "track-001".to_string(),
3619            source_node: "node-1".to_string(),
3620            position: Position {
3621                lat: 34.0522,
3622                lon: -118.2437,
3623                hae: Some(100.0),
3624            },
3625            velocity: Some(Velocity {
3626                bearing: 90.0,
3627                speed_mps: 10.0,
3628            }),
3629            classification: "a-f-G-U-C".to_string(),
3630            confidence: 0.95,
3631            cell_id: Some("cell-1".to_string()),
3632            formation_id: None,
3633        };
3634
3635        let result = encode_track_to_cot(track);
3636        assert!(result.is_ok());
3637
3638        let xml = result.unwrap();
3639        assert!(xml.contains("<event"));
3640        assert!(xml.contains("track-001"));
3641    }
3642
3643    #[test]
3644    fn test_encode_minimal_track() {
3645        let track = TrackData {
3646            track_id: "t1".to_string(),
3647            source_node: "p1".to_string(),
3648            position: Position {
3649                lat: 0.0,
3650                lon: 0.0,
3651                hae: None,
3652            },
3653            velocity: None,
3654            classification: "a-u-G".to_string(),
3655            confidence: 0.5,
3656            cell_id: None,
3657            formation_id: None,
3658        };
3659
3660        let result = encode_track_to_cot(track);
3661        assert!(result.is_ok());
3662    }
3663
3664    #[test]
3665    fn test_invalid_track_id() {
3666        let track = TrackData {
3667            track_id: "".to_string(), // Empty - should fail
3668            source_node: "p1".to_string(),
3669            position: Position {
3670                lat: 0.0,
3671                lon: 0.0,
3672                hae: None,
3673            },
3674            velocity: None,
3675            classification: "a-u-G".to_string(),
3676            confidence: 0.5,
3677            cell_id: None,
3678            formation_id: None,
3679        };
3680
3681        let result = encode_track_to_cot(track);
3682        assert!(result.is_err());
3683    }
3684
3685    #[test]
3686    fn test_helper_functions() {
3687        let pos = create_position(34.0, -118.0, Some(50.0));
3688        assert_eq!(pos.lat, 34.0);
3689        assert_eq!(pos.lon, -118.0);
3690        assert_eq!(pos.hae, Some(50.0));
3691
3692        let vel = create_velocity(45.0, 15.0);
3693        assert_eq!(vel.bearing, 45.0);
3694        assert_eq!(vel.speed_mps, 15.0);
3695    }
3696
3697    /// Tests for the generic `publish_document_into_node` helper that backs
3698    /// `Java_..._publishDocumentJni`. Foundation step 3 of the
3699    /// peat-mesh-completion / peat-btle-reduction work — see
3700    /// `PEAT-MESH-COMPLETION-0.9.0.md`.
3701    ///
3702    /// Running through `tokio::runtime::Runtime::block_on` rather than a
3703    /// `#[tokio::test]` attribute matches the rest of peat-ffi (which doesn't
3704    /// pull tokio macros into dev-dependencies just for tests) and exercises
3705    /// the same `runtime.block_on(...)` shape the JNI wrapper itself uses.
3706    #[cfg(feature = "sync")]
3707    mod publish_document_tests {
3708        use super::*;
3709        use peat_mesh::sync::traits::DataSyncBackend;
3710        use peat_mesh::sync::InMemoryBackend;
3711
3712        fn fresh_node() -> peat_mesh::Node {
3713            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
3714            peat_mesh::Node::new(backend)
3715        }
3716
3717        fn rt() -> tokio::runtime::Runtime {
3718            tokio::runtime::Builder::new_current_thread()
3719                .enable_all()
3720                .build()
3721                .expect("runtime")
3722        }
3723
3724        /// Publishing a JSON object with an explicit `"id"` field round-trips
3725        /// through the node: the returned id matches, and `node.get(...)`
3726        /// yields a Document carrying the body fields verbatim.
3727        #[test]
3728        fn round_trip_with_explicit_id() {
3729            let rt = rt();
3730            rt.block_on(async {
3731                let node = fresh_node();
3732                let json = r#"{
3733                    "id": "chat-001",
3734                    "sender": "ALPHA-1",
3735                    "text": "hello",
3736                    "timestamp": 1700000000000
3737                }"#;
3738                let id = publish_document_into_node(&node, "chats", json)
3739                    .await
3740                    .expect("publish");
3741                assert_eq!(id, "chat-001");
3742
3743                let got = node
3744                    .get("chats", &"chat-001".to_string())
3745                    .await
3746                    .expect("get")
3747                    .expect("found");
3748                assert_eq!(
3749                    got.fields.get("sender").and_then(|v| v.as_str()),
3750                    Some("ALPHA-1")
3751                );
3752                assert_eq!(
3753                    got.fields.get("text").and_then(|v| v.as_str()),
3754                    Some("hello")
3755                );
3756                assert!(
3757                    !got.fields.contains_key("id"),
3758                    "id is hoisted to Document::id, not duplicated in fields"
3759                );
3760            });
3761        }
3762
3763        /// JSON without an `"id"` field still publishes; the backend assigns
3764        /// one (UUID under `InMemoryBackend`). The returned id is non-empty
3765        /// and the doc is retrievable by it.
3766        #[test]
3767        fn id_assignment_when_absent() {
3768            let rt = rt();
3769            rt.block_on(async {
3770                let node = fresh_node();
3771                let json = r#"{"text":"orphan","sender":"BRAVO-2"}"#;
3772                let id = publish_document_into_node(&node, "chats", json)
3773                    .await
3774                    .expect("publish");
3775                assert!(!id.is_empty(), "backend must assign an id");
3776
3777                let got = node.get("chats", &id).await.expect("get").expect("found");
3778                assert_eq!(
3779                    got.fields.get("text").and_then(|v| v.as_str()),
3780                    Some("orphan")
3781                );
3782            });
3783        }
3784
3785        /// Malformed JSON returns Err — the JNI wrapper translates this into
3786        /// an empty-string return to the Java caller.
3787        #[test]
3788        fn malformed_json_errors() {
3789            let rt = rt();
3790            rt.block_on(async {
3791                let node = fresh_node();
3792                let result = publish_document_into_node(&node, "chats", "not-json").await;
3793                assert!(result.is_err());
3794            });
3795        }
3796
3797        /// Non-object JSON (array, string, number) returns Err — the
3798        /// document model requires an object at the top level.
3799        #[test]
3800        fn non_object_json_errors() {
3801            let rt = rt();
3802            rt.block_on(async {
3803                let node = fresh_node();
3804                let result = publish_document_into_node(&node, "chats", "[1, 2, 3]").await;
3805                assert!(result.is_err());
3806            });
3807        }
3808
3809        /// Non-string id (e.g. integer) is treated as id-absent — the backend
3810        /// assigns one rather than coercing the integer. Aligns with
3811        /// peat-protocol's `value_to_mesh_document`, which made the same
3812        /// decision in PR #802 round-1 review.
3813        #[test]
3814        fn non_string_id_falls_back_to_assigned() {
3815            let rt = rt();
3816            rt.block_on(async {
3817                let node = fresh_node();
3818                let json = r#"{"id":42,"text":"weird"}"#;
3819                let id = publish_document_into_node(&node, "chats", json)
3820                    .await
3821                    .expect("publish");
3822                assert_ne!(id, "42", "non-string id must be discarded, not coerced");
3823                assert!(!id.is_empty());
3824            });
3825        }
3826
3827        /// Origin-aware variant publishes successfully and threads the
3828        /// origin string through to peat-mesh. ADR-059 Amendment 2 Slice
3829        /// 1.b.4 requires this so the plugin's `BleDecodedDocumentBridge`
3830        /// can ingest 0xB6 frames into the doc store without re-emitting
3831        /// them back out to BLE — `Some("ble")` triggers the same
3832        /// loop-prevention fan-out skip the existing `ingestPositionJni`
3833        /// path uses.
3834        #[test]
3835        fn origin_variant_publishes_with_explicit_id() {
3836            let rt = rt();
3837            rt.block_on(async {
3838                let node = fresh_node();
3839                let json = r#"{"id":"ble-decoded-001","sender":"OBS-1","text":"x"}"#;
3840                let id = publish_document_into_node_with_origin(
3841                    &node,
3842                    "chats",
3843                    json,
3844                    Some("ble".to_string()),
3845                )
3846                .await
3847                .expect("publish_with_origin");
3848                assert_eq!(id, "ble-decoded-001");
3849
3850                let got = node
3851                    .get("chats", &"ble-decoded-001".to_string())
3852                    .await
3853                    .expect("get")
3854                    .expect("found");
3855                assert_eq!(
3856                    got.fields.get("sender").and_then(|v| v.as_str()),
3857                    Some("OBS-1")
3858                );
3859            });
3860        }
3861
3862        /// `None` origin makes the helper behave identically to the plain
3863        /// publish path — locks the back-compat invariant the wrapper
3864        /// `publish_document_into_node` relies on.
3865        #[test]
3866        fn origin_variant_with_none_matches_plain_publish() {
3867            let rt = rt();
3868            rt.block_on(async {
3869                let node = fresh_node();
3870                let json = r#"{"id":"plain-001","text":"plain"}"#;
3871                let id = publish_document_into_node_with_origin(&node, "chats", json, None)
3872                    .await
3873                    .expect("publish_with_origin(None)");
3874                assert_eq!(id, "plain-001");
3875
3876                let got = node
3877                    .get("chats", &"plain-001".to_string())
3878                    .await
3879                    .expect("get")
3880                    .expect("found");
3881                assert_eq!(
3882                    got.fields.get("text").and_then(|v| v.as_str()),
3883                    Some("plain")
3884                );
3885            });
3886        }
3887    }
3888
3889    /// Tests for the BLE-translator helpers backing the `ingest*Jni`
3890    /// family. Slice 1.b.2.2 of ADR-059 — the inbound BLE→Node→iroh path
3891    /// now goes directly through `BleTranslator` + `Node::publish_with_origin`
3892    /// (the legacy `BleGateway` wrapper was deleted; its responsibilities
3893    /// composed in-line here).
3894    #[cfg(all(feature = "sync", feature = "bluetooth"))]
3895    mod ingest_position_tests {
3896        use super::*;
3897        use peat_mesh::sync::traits::DataSyncBackend;
3898        use peat_mesh::sync::InMemoryBackend;
3899        use peat_protocol::sync::ble_translation::BleTranslator;
3900
3901        struct Fixture {
3902            translator: BleTranslator,
3903            node: peat_mesh::Node,
3904        }
3905
3906        fn fresh_fixture() -> Fixture {
3907            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
3908            Fixture {
3909                translator: BleTranslator::with_defaults(),
3910                node: peat_mesh::Node::new(backend),
3911            }
3912        }
3913
3914        fn rt() -> tokio::runtime::Runtime {
3915            tokio::runtime::Builder::new_current_thread()
3916                .enable_all()
3917                .build()
3918                .expect("runtime")
3919        }
3920
3921        /// Happy path: a fully-populated JSON envelope ingests into the
3922        /// tracks collection, the returned id is the translator's
3923        /// BLE-prefixed track id (`ble-` + uppercase 8-hex peripheral id),
3924        /// and the resulting Document carries the position fields plus
3925        /// `ble_origin: true` so any outbound BLE re-encoder filtering
3926        /// on that marker breaks the loop.
3927        #[test]
3928        fn round_trip_full_envelope() {
3929            let rt = rt();
3930            rt.block_on(async {
3931                let fx = fresh_fixture();
3932                // peripheral_id 0xCAFE0001 = 3_405_643_777 — sanity-check the
3933                // hex form by using a constant rather than hand-converting.
3934                const PERIPHERAL: u32 = 0xCAFE_0001;
3935                let json = format!(
3936                    r#"{{
3937                        "lat": 40.7128,
3938                        "lon": -74.0060,
3939                        "altitude": 100.0,
3940                        "accuracy": 5.0,
3941                        "peripheral_id": {},
3942                        "callsign": "SCOUT-CAFE",
3943                        "mesh_id": "29C916FA"
3944                    }}"#,
3945                    PERIPHERAL
3946                );
3947                let id = ingest_position_via_translator(&fx.translator, &fx.node, &json)
3948                    .await
3949                    .expect("ingest");
3950                // Translator format: ble_id_prefix ("ble-") + uppercase 8-hex.
3951                assert_eq!(id, format!("ble-{:08X}", PERIPHERAL));
3952
3953                let doc = fx
3954                    .node
3955                    .get(fx.translator.tracks_collection(), &id)
3956                    .await
3957                    .expect("get")
3958                    .expect("found");
3959                assert_eq!(
3960                    doc.fields.get("ble_origin"),
3961                    Some(&serde_json::Value::Bool(true)),
3962                    "ble_origin marker required for outbound loop suppression"
3963                );
3964            });
3965        }
3966
3967        /// Optional fields can be omitted: altitude, accuracy, callsign,
3968        /// mesh_id all default to None and the ingest still succeeds.
3969        #[test]
3970        fn omits_optional_fields() {
3971            let rt = rt();
3972            rt.block_on(async {
3973                let fx = fresh_fixture();
3974                let json = r#"{
3975                    "lat": 40.7128,
3976                    "lon": -74.0060,
3977                    "peripheral_id": 1
3978                }"#;
3979                let id = ingest_position_via_translator(&fx.translator, &fx.node, json)
3980                    .await
3981                    .expect("ingest");
3982                assert_eq!(id, "ble-00000001");
3983            });
3984        }
3985
3986        /// Missing required fields (lat/lon/peripheral_id) error rather
3987        /// than silently defaulting. The JNI wrapper translates the Err
3988        /// into an empty-string Java return.
3989        #[test]
3990        fn missing_required_fields_errors() {
3991            let rt = rt();
3992            rt.block_on(async {
3993                let fx = fresh_fixture();
3994                let json_no_lat = r#"{"lon": -74.0, "peripheral_id": 1}"#;
3995                assert!(
3996                    ingest_position_via_translator(&fx.translator, &fx.node, json_no_lat)
3997                        .await
3998                        .is_err()
3999                );
4000
4001                let json_no_id = r#"{"lat": 40.0, "lon": -74.0}"#;
4002                assert!(
4003                    ingest_position_via_translator(&fx.translator, &fx.node, json_no_id)
4004                        .await
4005                        .is_err()
4006                );
4007            });
4008        }
4009
4010        /// Malformed JSON errors (matches the contract of the JNI wrapper).
4011        #[test]
4012        fn malformed_json_errors() {
4013            let rt = rt();
4014            rt.block_on(async {
4015                let fx = fresh_fixture();
4016                let result =
4017                    ingest_position_via_translator(&fx.translator, &fx.node, "not-json").await;
4018                assert!(result.is_err());
4019            });
4020        }
4021
4022        /// Regression for PR #804 round-1 [WARNING]: a Kotlin caller that
4023        /// serializes peripheral_id from a signed `Int` field (rather than
4024        /// `Long`/`UInt`) emits a negative JSON literal for any u32 with
4025        /// the high bit set. The parser must reinterpret-cast through i32
4026        /// to recover the original u32; the resulting track id must match
4027        /// what the same u32 written as a positive literal produced.
4028        #[test]
4029        fn peripheral_id_negative_int_form_recovers_to_same_u32() {
4030            let rt = rt();
4031            rt.block_on(async {
4032                let fx = fresh_fixture();
4033                // 0xCAFE_0001 = 3_405_643_777 as u32; -889_323_519 is the
4034                // sign-extended Int form (verified: (3_405_643_777_i64 -
4035                // 4_294_967_296) == -889_323_519).
4036                const POSITIVE: i64 = 3_405_643_777;
4037                const NEGATIVE: i64 = -889_323_519;
4038                let expected_id = "ble-CAFE0001";
4039
4040                let positive_json = format!(
4041                    r#"{{ "lat": 40.0, "lon": -74.0, "peripheral_id": {} }}"#,
4042                    POSITIVE
4043                );
4044                let negative_json = format!(
4045                    r#"{{ "lat": 40.0, "lon": -74.0, "peripheral_id": {} }}"#,
4046                    NEGATIVE
4047                );
4048
4049                let id_pos =
4050                    ingest_position_via_translator(&fx.translator, &fx.node, &positive_json)
4051                        .await
4052                        .expect("positive form ingests");
4053                assert_eq!(id_pos, expected_id);
4054
4055                let id_neg =
4056                    ingest_position_via_translator(&fx.translator, &fx.node, &negative_json)
4057                        .await
4058                        .expect("negative (Kotlin Int) form ingests");
4059                assert_eq!(
4060                    id_neg, expected_id,
4061                    "both forms must yield the same track id"
4062                );
4063            });
4064        }
4065
4066        /// Out-of-range values reject rather than silently truncate.
4067        /// Without bounds-checking, a >u32::MAX value would `as u32`
4068        /// truncate and collide distinct logical IDs onto the same
4069        /// translator-emitted track id, mis-attributing positions.
4070        #[test]
4071        fn peripheral_id_out_of_range_errors() {
4072            let rt = rt();
4073            rt.block_on(async {
4074                let fx = fresh_fixture();
4075
4076                // u32::MAX + 1
4077                let too_big = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": 4294967296 }"#;
4078                assert!(
4079                    ingest_position_via_translator(&fx.translator, &fx.node, too_big)
4080                        .await
4081                        .is_err()
4082                );
4083
4084                // i32::MIN - 1
4085                let too_small = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": -2147483649 }"#;
4086                assert!(
4087                    ingest_position_via_translator(&fx.translator, &fx.node, too_small)
4088                        .await
4089                        .is_err()
4090                );
4091            });
4092        }
4093
4094        /// u32::MAX and i32::MIN are valid boundaries. u32::MAX exercises
4095        /// the top of the positive form; i32::MIN exercises the top of the
4096        /// negative-Int form (a u32 with `high_bit=1, rest=0` =
4097        /// `0x8000_0000` = `-2_147_483_648` as Int).
4098        #[test]
4099        fn peripheral_id_boundaries_accepted() {
4100            let rt = rt();
4101            rt.block_on(async {
4102                let fx = fresh_fixture();
4103
4104                let max_json = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": 4294967295 }"#;
4105                let id = ingest_position_via_translator(&fx.translator, &fx.node, max_json)
4106                    .await
4107                    .expect("u32::MAX");
4108                assert_eq!(id, "ble-FFFFFFFF");
4109
4110                let min_int_json = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": -2147483648 }"#;
4111                let id = ingest_position_via_translator(&fx.translator, &fx.node, min_int_json)
4112                    .await
4113                    .expect("i32::MIN as Int form");
4114                assert_eq!(id, "ble-80000000");
4115            });
4116        }
4117
4118        /// Slice 1.b.2.2: the rewire publishes through
4119        /// `Node::publish_with_origin(.., Some("ble"))`, so the resulting
4120        /// `ChangeEvent::Updated` must carry `origin = Some("ble")`. This
4121        /// is the load-bearing assertion that `TransportManager` fan-out
4122        /// can suppress the BLE→Node→observer→BLE same-node echo without
4123        /// it, the loop-break invariant is gone.
4124        #[tokio::test]
4125        async fn ingest_emits_observer_event_with_ble_origin() {
4126            use peat_mesh::sync::types::{ChangeEvent, Query};
4127            let fx = fresh_fixture();
4128            let mut tracks = fx
4129                .node
4130                .observe(fx.translator.tracks_collection(), &Query::All)
4131                .expect("observe");
4132
4133            let json = r#"{
4134                "lat": 40.7,
4135                "lon": -74.0,
4136                "peripheral_id": 1,
4137                "callsign": "SCOUT-1"
4138            }"#;
4139            let _ = ingest_position_via_translator(&fx.translator, &fx.node, json)
4140                .await
4141                .expect("ingest");
4142
4143            // Skip the Initial snapshot, then assert the Updated event's origin.
4144            loop {
4145                let ev = tracks.receiver.recv().await.expect("event");
4146                if let ChangeEvent::Updated { origin, .. } = ev {
4147                    assert_eq!(
4148                        origin,
4149                        Some("ble".to_string()),
4150                        "ingestPositionJni must publish with Some(\"ble\") origin per ADR-059"
4151                    );
4152                    break;
4153                }
4154            }
4155        }
4156    }
4157
4158    /// Tests for the outbound BLE-frame fan-out path (ADR-059 Slice 1.b.2).
4159    /// The JNI surface itself can't be exercised without a JVM, but the
4160    /// underlying mechanism — `TransportManager` registers a translator + sink,
4161    /// observer pushes through encode_outbound, sink receives bytes — is fully
4162    /// exercisable with a recording sink standing in for `JniOutboundSink`.
4163    #[cfg(all(feature = "sync", feature = "bluetooth"))]
4164    mod outbound_frame_tests {
4165        use super::*;
4166        use peat_mesh::sync::traits::DataSyncBackend;
4167        use peat_mesh::sync::InMemoryBackend;
4168        use peat_mesh::transport::{
4169            FanoutHandle, OutboundSink, TranslationContext, Translator,
4170            TranslatorRegistrationConfig,
4171        };
4172        use peat_protocol::sync::ble_translation::BleTranslator;
4173        use peat_protocol::transport::{TransportManager, TransportManagerConfig};
4174        use std::sync::Mutex as StdMutex;
4175        use tokio::time::{timeout, Duration};
4176
4177        /// Records `(transport_id, collection, bytes)` triples each time
4178        /// `send_outbound` fires. Stand-in for the JNI dispatcher in unit
4179        /// tests — we assert against the recorded frames rather than calling
4180        /// into a JVM.
4181        #[derive(Default)]
4182        struct RecordingSink {
4183            frames: StdMutex<Vec<(String, String, Vec<u8>)>>,
4184        }
4185
4186        #[async_trait::async_trait]
4187        impl OutboundSink for RecordingSink {
4188            async fn send_outbound(
4189                &self,
4190                bytes: Vec<u8>,
4191                ctx: &TranslationContext,
4192            ) -> anyhow::Result<()> {
4193                let collection = ctx.collection.clone().unwrap_or_default();
4194                self.frames
4195                    .lock()
4196                    .unwrap()
4197                    .push(("ble".to_string(), collection, bytes));
4198                Ok(())
4199            }
4200        }
4201
4202        impl RecordingSink {
4203            fn snapshot(&self) -> Vec<(String, String, Vec<u8>)> {
4204                self.frames.lock().unwrap().clone()
4205            }
4206        }
4207
4208        struct Fixture {
4209            node: Arc<peat_mesh::Node>,
4210            translator: Arc<BleTranslator>,
4211            transport_manager: TransportManager,
4212            sink: Arc<RecordingSink>,
4213        }
4214
4215        fn fixture() -> Fixture {
4216            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4217            Fixture {
4218                node: Arc::new(peat_mesh::Node::new(backend)),
4219                translator: Arc::new(BleTranslator::with_defaults()),
4220                transport_manager: TransportManager::new(TransportManagerConfig::default()),
4221                sink: Arc::new(RecordingSink::default()),
4222            }
4223        }
4224
4225        async fn register_and_start(fx: &Fixture) -> anyhow::Result<FanoutHandle> {
4226            let translator_dyn: Arc<dyn Translator> = fx.translator.clone();
4227            let sink_dyn: Arc<dyn OutboundSink> = fx.sink.clone();
4228            fx.transport_manager
4229                .register_translator(
4230                    translator_dyn,
4231                    sink_dyn,
4232                    TranslatorRegistrationConfig::ble(),
4233                )
4234                .await?;
4235            fx.transport_manager.start_fanout(
4236                Arc::clone(&fx.node),
4237                vec![fx.translator.tracks_collection().to_string()],
4238            )
4239        }
4240
4241        /// Wait up to 1s for the recording sink to receive at least
4242        /// `expected_count` frames. The fan-out is asynchronous (observer
4243        /// task → channel → drain task → sink), so a brief poll loop is
4244        /// the right shape — fixed sleeps would be flaky.
4245        async fn wait_for_frames(sink: &RecordingSink, expected: usize) {
4246            let _ = timeout(Duration::from_secs(1), async {
4247                loop {
4248                    if sink.snapshot().len() >= expected {
4249                        return;
4250                    }
4251                    tokio::time::sleep(Duration::from_millis(20)).await;
4252                }
4253            })
4254            .await;
4255        }
4256
4257        /// Baseline: a doc published via the iroh-side bridge (no
4258        /// `Some("ble")` origin) reaches the BLE sink — the
4259        /// translator-encode + drain-task path is wired correctly.
4260        #[tokio::test]
4261        async fn iroh_origin_doc_reaches_ble_sink() {
4262            let fx = fixture();
4263            let _h = register_and_start(&fx).await.expect("register");
4264
4265            // No origin = "iroh-side" doc. The fan-out should encode + deliver.
4266            let doc = peat_mesh::sync::types::Document::with_id("ble-00000001".to_string(), {
4267                let mut f = std::collections::HashMap::new();
4268                f.insert("lat".to_string(), serde_json::json!(40.0));
4269                f.insert("lon".to_string(), serde_json::json!(-74.0));
4270                f.insert(
4271                    "source_node".to_string(),
4272                    serde_json::json!("iroh-00000001"),
4273                );
4274                f.insert("hae".to_string(), serde_json::json!(100.0));
4275                f.insert("cep".to_string(), serde_json::json!(5.0));
4276                f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4277                f.insert("confidence".to_string(), serde_json::json!(0.9));
4278                f.insert("category".to_string(), serde_json::json!("friendly"));
4279                f.insert("callsign".to_string(), serde_json::json!("ALPHA-1"));
4280                f.insert(
4281                    "created_at".to_string(),
4282                    serde_json::json!(1_700_000_000_000_i64),
4283                );
4284                f.insert(
4285                    "last_update".to_string(),
4286                    serde_json::json!(1_700_000_000_000_i64),
4287                );
4288                f
4289            });
4290            fx.node.publish("tracks", doc).await.expect("publish");
4291
4292            wait_for_frames(&fx.sink, 1).await;
4293            let frames = fx.sink.snapshot();
4294            assert!(
4295                !frames.is_empty(),
4296                "iroh-origin track must reach ble sink; got 0 frames"
4297            );
4298            let (transport, collection, bytes) = &frames[0];
4299            assert_eq!(transport, "ble");
4300            assert_eq!(collection, "tracks");
4301            assert!(!bytes.is_empty(), "encoded bytes must be non-empty");
4302        }
4303
4304        /// Loop suppression: a doc with `origin = Some("ble")` (i.e.
4305        /// ingestPositionJni's output) MUST NOT be re-encoded back out the
4306        /// BLE sink. This is the same-node echo-loop break ADR-059 §
4307        /// "Origin propagation" requires.
4308        #[tokio::test]
4309        async fn ble_origin_doc_does_not_re_encode_to_ble_sink() {
4310            let fx = fixture();
4311            let _h = register_and_start(&fx).await.expect("register");
4312
4313            let doc = peat_mesh::sync::types::Document::with_id("ble-CAFE0001".to_string(), {
4314                let mut f = std::collections::HashMap::new();
4315                f.insert("lat".to_string(), serde_json::json!(40.0));
4316                f.insert("lon".to_string(), serde_json::json!(-74.0));
4317                f.insert("ble_origin".to_string(), serde_json::json!(true));
4318                f
4319            });
4320
4321            fx.node
4322                .publish_with_origin("tracks", doc, Some("ble".to_string()))
4323                .await
4324                .expect("publish");
4325
4326            // Hold the awaited window slightly past the steady-state
4327            // observer fan-out latency; if loop suppression is broken,
4328            // the sink would have received the encoded frame by now.
4329            tokio::time::sleep(Duration::from_millis(150)).await;
4330
4331            let frames = fx.sink.snapshot();
4332            assert!(
4333                frames.is_empty(),
4334                "ble-origin doc must be suppressed from outbound BLE \
4335                 (ADR-059 same-node echo break); got {} frames",
4336                frames.len()
4337            );
4338        }
4339
4340        /// Dropping the `FanoutHandle` (mirroring
4341        /// `unsubscribeOutboundFramesJni`'s teardown) stops further
4342        /// frames from reaching the sink.
4343        #[tokio::test]
4344        async fn drop_handle_stops_subsequent_delivery() {
4345            let fx = fixture();
4346            let h = register_and_start(&fx).await.expect("register");
4347
4348            // Sanity: first publish reaches sink.
4349            fx.node
4350                .publish(
4351                    "tracks",
4352                    peat_mesh::sync::types::Document::with_id("ble-00000001".to_string(), {
4353                        let mut f = std::collections::HashMap::new();
4354                        f.insert("lat".to_string(), serde_json::json!(40.0));
4355                        f.insert("lon".to_string(), serde_json::json!(-74.0));
4356                        f.insert("source_node".to_string(), serde_json::json!("iroh-1"));
4357                        f.insert("callsign".to_string(), serde_json::json!("A"));
4358                        f.insert("hae".to_string(), serde_json::json!(0.0));
4359                        f.insert("cep".to_string(), serde_json::json!(0.0));
4360                        f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4361                        f.insert("confidence".to_string(), serde_json::json!(0.5));
4362                        f.insert("category".to_string(), serde_json::json!("friendly"));
4363                        f.insert(
4364                            "created_at".to_string(),
4365                            serde_json::json!(1_700_000_000_000_i64),
4366                        );
4367                        f.insert(
4368                            "last_update".to_string(),
4369                            serde_json::json!(1_700_000_000_000_i64),
4370                        );
4371                        f
4372                    }),
4373                )
4374                .await
4375                .expect("publish-1");
4376            wait_for_frames(&fx.sink, 1).await;
4377            let pre_drop_count = fx.sink.snapshot().len();
4378            assert!(pre_drop_count >= 1);
4379
4380            // Drop the handle — observer tasks for this fan-out cancel.
4381            // The cancellation token is set synchronously on drop, but the
4382            // observer task only notices on its next `select!` poll, so we
4383            // yield+sleep briefly to let the runtime actually cancel the
4384            // task before producing the new broadcast. Without this gap,
4385            // tokio::select!'s non-biased polling may race the new event
4386            // ahead of the cancellation arm. (peat-mesh's observer_task
4387            // would benefit from `biased;` to make this deterministic;
4388            // tracked as a Slice 2 hardening item.)
4389            drop(h);
4390            tokio::time::sleep(Duration::from_millis(50)).await;
4391
4392            // Publish AFTER cancellation has settled. Use a distinct doc
4393            // id so any leaked frame would be visibly separate from
4394            // pre-drop traffic.
4395            fx.node
4396                .publish(
4397                    "tracks",
4398                    peat_mesh::sync::types::Document::with_id("ble-00000002".to_string(), {
4399                        let mut f = std::collections::HashMap::new();
4400                        f.insert("lat".to_string(), serde_json::json!(41.0));
4401                        f.insert("lon".to_string(), serde_json::json!(-75.0));
4402                        f.insert("source_node".to_string(), serde_json::json!("iroh-2"));
4403                        f.insert("callsign".to_string(), serde_json::json!("B"));
4404                        f.insert("hae".to_string(), serde_json::json!(0.0));
4405                        f.insert("cep".to_string(), serde_json::json!(0.0));
4406                        f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4407                        f.insert("confidence".to_string(), serde_json::json!(0.5));
4408                        f.insert("category".to_string(), serde_json::json!("friendly"));
4409                        f.insert(
4410                            "created_at".to_string(),
4411                            serde_json::json!(1_700_000_000_001_i64),
4412                        );
4413                        f.insert(
4414                            "last_update".to_string(),
4415                            serde_json::json!(1_700_000_000_001_i64),
4416                        );
4417                        f
4418                    }),
4419                )
4420                .await
4421                .expect("publish-2");
4422
4423            tokio::time::sleep(Duration::from_millis(200)).await;
4424
4425            let post_drop_count = fx.sink.snapshot().len();
4426            assert_eq!(
4427                post_drop_count, pre_drop_count,
4428                "no frames must arrive after FanoutHandle drop"
4429            );
4430        }
4431
4432        /// Re-register after teardown succeeds — the unsubscribe path is
4433        /// exercised against a clean slate. Mirrors the
4434        /// `unsubscribeOutboundFramesJni` → `subscribeOutboundFramesJni` JNI
4435        /// flow.
4436        #[tokio::test]
4437        async fn re_register_after_unregister_succeeds() {
4438            let fx = fixture();
4439            let h = register_and_start(&fx).await.expect("register-1");
4440            drop(h);
4441            fx.transport_manager
4442                .unregister_translator("ble")
4443                .await
4444                .expect("unregister");
4445
4446            // Second register must succeed (no transport_id collision).
4447            let _h2 = register_and_start(&fx).await.expect("register-2");
4448        }
4449
4450        /// Double-register on the same `transport_id` rejects with the
4451        /// ADR-059 §"Transport ID uniqueness" invariant. The JNI
4452        /// `subscribeOutboundFramesJni` defends against this by checking
4453        /// the FanoutHandle slot before re-registering — this test guards
4454        /// the underlying invariant the JNI relies on.
4455        #[tokio::test]
4456        async fn double_register_rejects() {
4457            let fx = fixture();
4458            let _h = register_and_start(&fx).await.expect("register-1");
4459            let result = register_and_start(&fx).await;
4460            assert!(
4461                result.is_err(),
4462                "second register on same transport_id must error"
4463            );
4464        }
4465
4466        // ----- Poll-API unit tests -----
4467
4468        /// `QueueOutboundSink::send_outbound` enqueues frames that can be
4469        /// drained via the queue directly — mirrors what `poll_outbound_frames`
4470        /// does at the `PeatNode` level.
4471        #[tokio::test]
4472        async fn queue_sink_enqueues_frames() {
4473            let queue = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
4474                OutboundFrame,
4475            >::new()));
4476            let sink = QueueOutboundSink {
4477                transport_id: "ble",
4478                queue: Arc::clone(&queue),
4479            };
4480            let ctx = TranslationContext::inbound("ble").with_collection("tracks");
4481            sink.send_outbound(vec![0xAA, 0xBB], &ctx).await.unwrap();
4482            sink.send_outbound(vec![0xCC], &ctx).await.unwrap();
4483
4484            let frames: Vec<OutboundFrame> = queue.lock().unwrap().drain(..).collect();
4485            assert_eq!(frames.len(), 2);
4486            assert_eq!(frames[0].transport_id, "ble");
4487            assert_eq!(frames[0].collection, "tracks");
4488            assert_eq!(frames[0].bytes, vec![0xAA, 0xBB]);
4489            assert_eq!(frames[1].bytes, vec![0xCC]);
4490        }
4491
4492        /// A document published via the fan-out path reaches the
4493        /// `QueueOutboundSink`, confirming the poll-API wiring matches the
4494        /// existing `RecordingSink`-based path. Mirrors
4495        /// `iroh_origin_doc_reaches_ble_sink`.
4496        #[tokio::test]
4497        async fn queue_sink_receives_fanned_out_doc() {
4498            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4499            let node = Arc::new(peat_mesh::Node::new(backend));
4500            let translator = Arc::new(BleTranslator::with_defaults());
4501            let tm = TransportManager::new(TransportManagerConfig::default());
4502            let queue = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
4503                OutboundFrame,
4504            >::new()));
4505            let sink: Arc<dyn OutboundSink> = Arc::new(QueueOutboundSink {
4506                transport_id: "ble",
4507                queue: Arc::clone(&queue),
4508            });
4509            let translator_dyn: Arc<dyn Translator> = translator.clone();
4510            tm.register_translator(translator_dyn, sink, TranslatorRegistrationConfig::ble())
4511                .await
4512                .expect("register");
4513            let _h = tm
4514                .start_fanout(
4515                    Arc::clone(&node),
4516                    vec![translator.tracks_collection().to_string()],
4517                )
4518                .expect("start_fanout");
4519
4520            let doc = peat_mesh::sync::types::Document::with_id("q-00000001".to_string(), {
4521                let mut f = std::collections::HashMap::new();
4522                f.insert("lat".to_string(), serde_json::json!(51.5));
4523                f.insert("lon".to_string(), serde_json::json!(-0.1));
4524                f.insert("source_platform".to_string(), serde_json::json!("iroh-q01"));
4525                f.insert("hae".to_string(), serde_json::json!(10.0));
4526                f.insert("cep".to_string(), serde_json::json!(2.0));
4527                f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4528                f.insert("confidence".to_string(), serde_json::json!(0.8));
4529                f.insert("category".to_string(), serde_json::json!("friendly"));
4530                f.insert("callsign".to_string(), serde_json::json!("BRAVO-1"));
4531                f.insert(
4532                    "created_at".to_string(),
4533                    serde_json::json!(1_700_000_001_000_i64),
4534                );
4535                f
4536            });
4537            node.publish(translator.tracks_collection(), doc)
4538                .await
4539                .expect("publish");
4540
4541            let _ = timeout(Duration::from_secs(1), async {
4542                loop {
4543                    if !queue.lock().unwrap().is_empty() {
4544                        return;
4545                    }
4546                    tokio::time::sleep(Duration::from_millis(20)).await;
4547                }
4548            })
4549            .await;
4550
4551            let frames: Vec<OutboundFrame> = queue.lock().unwrap().drain(..).collect();
4552            assert!(
4553                !frames.is_empty(),
4554                "queue sink must receive at least one frame"
4555            );
4556            assert_eq!(frames[0].transport_id, "ble");
4557            assert_eq!(frames[0].collection, translator.tracks_collection());
4558        }
4559
4560        /// `ingest_inbound_frame` round-trips: produce postcard bytes via
4561        /// `BleTranslator::encode_outbound` (the same path the real fan-out
4562        /// uses), then decode them back through `decode_inbound` and publish
4563        /// with `Some("ble")` origin (ADR-059 echo-suppression invariant).
4564        /// Tests the same primitives that `PeatNode::ingest_inbound_frame`
4565        /// uses.
4566        #[tokio::test]
4567        async fn ingest_inbound_frame_roundtrip_publishes_with_ble_origin() {
4568            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4569            let node = Arc::new(peat_mesh::Node::new(backend));
4570            let translator = Arc::new(BleTranslator::with_defaults());
4571
4572            // Build a minimal tracks document and encode it to postcard bytes.
4573            let outbound_doc =
4574                peat_mesh::sync::types::Document::with_id("enc-00000001".to_string(), {
4575                    let mut f = std::collections::HashMap::new();
4576                    f.insert("lat".to_string(), serde_json::json!(48.858));
4577                    f.insert("lon".to_string(), serde_json::json!(2.294));
4578                    f.insert(
4579                        "source_platform".to_string(),
4580                        serde_json::json!("iroh-enc01"),
4581                    );
4582                    f.insert("hae".to_string(), serde_json::json!(50.0));
4583                    f.insert("cep".to_string(), serde_json::json!(3.0));
4584                    f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4585                    f.insert("confidence".to_string(), serde_json::json!(0.9));
4586                    f.insert("category".to_string(), serde_json::json!("friendly"));
4587                    f.insert("callsign".to_string(), serde_json::json!("DELTA-1"));
4588                    f.insert(
4589                        "created_at".to_string(),
4590                        serde_json::json!(1_700_000_002_000_i64),
4591                    );
4592                    f
4593                });
4594            let encode_ctx = TranslationContext::inbound("ble")
4595                .with_collection(translator.tracks_collection().to_string());
4596            let postcard_bytes = translator
4597                .encode_outbound(&outbound_doc, &encode_ctx)
4598                .await
4599                .expect("encode_outbound should produce Some bytes for a tracks doc");
4600
4601            // Decode — mirrors what `ingest_inbound_frame` does.
4602            let decode_ctx = TranslationContext::inbound("ble")
4603                .with_collection(translator.tracks_collection().to_string());
4604            let decoded = translator
4605                .decode_inbound(&postcard_bytes, &decode_ctx)
4606                .await
4607                .expect("decode_inbound")
4608                .expect("should produce a document for tracks");
4609
4610            // Publish with ble origin so echo-suppression fires correctly.
4611            let id = node
4612                .publish_with_origin(
4613                    translator.tracks_collection(),
4614                    decoded,
4615                    Some("ble".to_string()),
4616                )
4617                .await
4618                .expect("publish");
4619
4620            // Verify the doc landed in the store.
4621            let stored = node
4622                .get(translator.tracks_collection(), &id)
4623                .await
4624                .expect("get")
4625                .expect("doc must be present after ingest");
4626            assert!(
4627                stored.fields.contains_key("lat"),
4628                "decoded document must contain lat field"
4629            );
4630        }
4631    }
4632
4633    /// Universal-Document path coexistence with the typed BLE path.
4634    /// Locks the load-bearing invariant for ADR-035 / ADR-059 Slice 1.b
4635    /// "scope #3": both translators register on the same physical wire
4636    /// under distinct transport_ids, the catch-all `LiteBridgeTranslator`
4637    /// is gated by `CollectionGatedLiteBridge` so it doesn't double-emit
4638    /// on the typed BleTranslator's collections, and origin-skip
4639    /// disambiguates each codec's emission independently.
4640    #[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
4641    mod lite_bridge_outbound_frame_tests {
4642        use super::*;
4643        use peat_mesh::sync::traits::DataSyncBackend;
4644        use peat_mesh::sync::InMemoryBackend;
4645        use peat_mesh::transport::{
4646            FanoutHandle, OutboundSink, TranslationContext, Translator,
4647            TranslatorRegistrationConfig, BLE_LITE_BRIDGE,
4648        };
4649        use peat_protocol::sync::ble_translation::BleTranslator;
4650        use peat_protocol::transport::{TransportManager, TransportManagerConfig};
4651        use std::sync::Mutex as StdMutex;
4652        use tokio::time::{timeout, Duration};
4653
4654        /// Like the typed-BLE `RecordingSink`, but stores its own
4655        /// transport_id so two parallel sinks can be told apart.
4656        struct TaggedRecordingSink {
4657            transport_id: &'static str,
4658            frames: StdMutex<Vec<(String, String, Vec<u8>)>>,
4659        }
4660
4661        #[async_trait::async_trait]
4662        impl OutboundSink for TaggedRecordingSink {
4663            async fn send_outbound(
4664                &self,
4665                bytes: Vec<u8>,
4666                ctx: &TranslationContext,
4667            ) -> anyhow::Result<()> {
4668                let collection = ctx.collection.clone().unwrap_or_default();
4669                self.frames.lock().unwrap().push((
4670                    self.transport_id.to_string(),
4671                    collection,
4672                    bytes,
4673                ));
4674                Ok(())
4675            }
4676        }
4677
4678        impl TaggedRecordingSink {
4679            fn new(transport_id: &'static str) -> Arc<Self> {
4680                Arc::new(Self {
4681                    transport_id,
4682                    frames: StdMutex::new(Vec::new()),
4683                })
4684            }
4685
4686            fn snapshot(&self) -> Vec<(String, String, Vec<u8>)> {
4687                self.frames.lock().unwrap().clone()
4688            }
4689        }
4690
4691        async fn wait_for_any(sinks: &[&Arc<TaggedRecordingSink>], min_total: usize) {
4692            let _ = timeout(Duration::from_secs(1), async {
4693                loop {
4694                    let total: usize = sinks.iter().map(|s| s.snapshot().len()).sum();
4695                    if total >= min_total {
4696                        return;
4697                    }
4698                    tokio::time::sleep(Duration::from_millis(20)).await;
4699                }
4700            })
4701            .await;
4702        }
4703
4704        struct CoexistenceFixture {
4705            node: Arc<peat_mesh::Node>,
4706            transport_manager: TransportManager,
4707            ble_sink: Arc<TaggedRecordingSink>,
4708            lite_sink: Arc<TaggedRecordingSink>,
4709        }
4710
4711        async fn coexistence_fixture() -> (CoexistenceFixture, FanoutHandle) {
4712            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4713            let node = Arc::new(peat_mesh::Node::new(backend));
4714            let mgr = TransportManager::new(TransportManagerConfig::default());
4715
4716            let ble_translator = Arc::new(BleTranslator::with_defaults());
4717            let ble_sink = TaggedRecordingSink::new("ble");
4718            let ble_translator_dyn: Arc<dyn Translator> = ble_translator.clone();
4719            let ble_sink_dyn: Arc<dyn OutboundSink> = ble_sink.clone();
4720            mgr.register_translator(
4721                ble_translator_dyn,
4722                ble_sink_dyn,
4723                TranslatorRegistrationConfig::ble(),
4724            )
4725            .await
4726            .expect("register typed BLE");
4727
4728            let lite_translator: Arc<dyn Translator> = Arc::new(
4729                CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS),
4730            );
4731            let lite_sink = TaggedRecordingSink::new(BLE_LITE_BRIDGE);
4732            let lite_sink_dyn: Arc<dyn OutboundSink> = lite_sink.clone();
4733            mgr.register_translator(
4734                lite_translator,
4735                lite_sink_dyn,
4736                TranslatorRegistrationConfig::ble(),
4737            )
4738            .await
4739            .expect("register lite-bridge");
4740
4741            // Observe both typed and universal-Document collections —
4742            // matches the production `subscribeOutboundFramesJni` shape.
4743            let mut collections = vec![
4744                ble_translator.tracks_collection().to_string(),
4745                ble_translator.nodes_collection().to_string(),
4746            ];
4747            for c in LITE_BRIDGE_COLLECTIONS {
4748                collections.push((*c).to_string());
4749            }
4750
4751            let handle = mgr
4752                .start_fanout(Arc::clone(&node), collections)
4753                .expect("start_fanout");
4754
4755            (
4756                CoexistenceFixture {
4757                    node,
4758                    transport_manager: mgr,
4759                    ble_sink,
4760                    lite_sink,
4761                },
4762                handle,
4763            )
4764        }
4765
4766        fn marker_doc(uuid: &str) -> peat_mesh::sync::types::Document {
4767            let mut fields = std::collections::HashMap::new();
4768            fields.insert("type".to_string(), serde_json::json!("a-f-G-U-C"));
4769            fields.insert("lat".to_string(), serde_json::json!(33.71));
4770            fields.insert("lon".to_string(), serde_json::json!(-84.41));
4771            peat_mesh::sync::types::Document::with_id(uuid.to_string(), fields)
4772        }
4773
4774        fn track_doc(uuid: &str) -> peat_mesh::sync::types::Document {
4775            // Minimum field set BleTranslator's track-encode requires.
4776            let mut f = std::collections::HashMap::new();
4777            f.insert("lat".to_string(), serde_json::json!(40.0));
4778            f.insert("lon".to_string(), serde_json::json!(-74.0));
4779            f.insert("source_node".to_string(), serde_json::json!("iroh-1"));
4780            f.insert("hae".to_string(), serde_json::json!(0.0));
4781            f.insert("cep".to_string(), serde_json::json!(0.0));
4782            f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4783            f.insert("confidence".to_string(), serde_json::json!(0.5));
4784            f.insert("category".to_string(), serde_json::json!("friendly"));
4785            f.insert("callsign".to_string(), serde_json::json!("ALPHA-1"));
4786            f.insert(
4787                "created_at".to_string(),
4788                serde_json::json!(1_700_000_000_000_i64),
4789            );
4790            f.insert(
4791                "last_update".to_string(),
4792                serde_json::json!(1_700_000_000_000_i64),
4793            );
4794            peat_mesh::sync::types::Document::with_id(uuid.to_string(), f)
4795        }
4796
4797        /// A doc on `"markers"` (universal-Document collection) reaches
4798        /// the lite-bridge sink only — the typed BleTranslator declines
4799        /// the unknown collection silently, so the typed sink stays
4800        /// empty. The lite-bridge sink's bytes round-trip back through
4801        /// the codec to the original Document fields.
4802        #[tokio::test]
4803        async fn marker_publish_reaches_only_lite_bridge_sink() {
4804            let (fx, _h) = coexistence_fixture().await;
4805
4806            let doc = marker_doc("marker-uuid-001");
4807            let original_fields = doc.fields.clone();
4808            fx.node
4809                .publish_with_origin("markers", doc, Some("self".to_string()))
4810                .await
4811                .expect("publish marker");
4812
4813            wait_for_any(&[&fx.ble_sink, &fx.lite_sink], 1).await;
4814
4815            let ble_frames = fx.ble_sink.snapshot();
4816            let lite_frames = fx.lite_sink.snapshot();
4817
4818            assert!(
4819                ble_frames.is_empty(),
4820                "typed BLE sink MUST decline 'markers' (unknown collection); \
4821                 got {} frames",
4822                ble_frames.len()
4823            );
4824            assert_eq!(
4825                lite_frames.len(),
4826                1,
4827                "lite-bridge sink should see exactly one envelope for the marker"
4828            );
4829            let (transport_id, collection, bytes) = &lite_frames[0];
4830            assert_eq!(transport_id, BLE_LITE_BRIDGE);
4831            assert_eq!(collection, "markers");
4832
4833            // Round-trip the bytes back through the codec — proves the
4834            // wire frame is well-formed and reconstructs the original
4835            // Document fields.
4836            let (envelope_collection, decoded) =
4837                peat_mesh::transport::document_codec::decode_document(bytes)
4838                    .expect("decode envelope");
4839            assert_eq!(envelope_collection, "markers");
4840            assert_eq!(decoded.id.as_deref(), Some("marker-uuid-001"));
4841            assert_eq!(decoded.fields, original_fields);
4842        }
4843
4844        /// Tombstone variant of the markers-collection fanout path.
4845        /// A doc carrying `_deleted: true` on the `"markers"`
4846        /// collection must reach the lite-bridge sink with the
4847        /// sentinel preserved end-to-end. peat-mesh's fan-out skips
4848        /// `ChangeEvent::Removed` today (Slice-2 work); the soft-
4849        /// delete sentinel rides the Updated channel via this same
4850        /// path. If the codec drops the `_deleted` key in either
4851        /// direction, deletions never propagate and markers reappear
4852        /// on peers after every refresh — the failure mode that
4853        /// motivated this PR. Re-decoding the envelope bytes confirms
4854        /// the wire shape carries the flag.
4855        #[tokio::test]
4856        async fn marker_tombstone_publish_reaches_lite_bridge_sink_with_deleted_flag() {
4857            let (fx, _h) = coexistence_fixture().await;
4858
4859            let mut fields = std::collections::HashMap::new();
4860            fields.insert("_deleted".to_string(), serde_json::json!(true));
4861            fields.insert("ts".to_string(), serde_json::json!(1_700_000_000_000_i64));
4862            let doc = peat_mesh::sync::types::Document::with_id(
4863                "marker-tombstone-001".to_string(),
4864                fields.clone(),
4865            );
4866
4867            fx.node
4868                .publish_with_origin("markers", doc, Some("self".to_string()))
4869                .await
4870                .expect("publish tombstone");
4871
4872            wait_for_any(&[&fx.ble_sink, &fx.lite_sink], 1).await;
4873
4874            let ble_frames = fx.ble_sink.snapshot();
4875            let lite_frames = fx.lite_sink.snapshot();
4876            assert!(
4877                ble_frames.is_empty(),
4878                "typed BLE sink MUST decline 'markers' tombstone (unknown collection)"
4879            );
4880            assert_eq!(
4881                lite_frames.len(),
4882                1,
4883                "lite-bridge sink should see exactly one envelope for the tombstone"
4884            );
4885            let (_, collection, bytes) = &lite_frames[0];
4886            assert_eq!(collection, "markers");
4887
4888            let (envelope_collection, decoded) =
4889                peat_mesh::transport::document_codec::decode_document(bytes)
4890                    .expect("decode tombstone envelope");
4891            assert_eq!(envelope_collection, "markers");
4892            assert_eq!(decoded.id.as_deref(), Some("marker-tombstone-001"));
4893            assert_eq!(
4894                decoded.fields.get("_deleted"),
4895                Some(&serde_json::json!(true)),
4896                "tombstone _deleted: true must survive the BLE wire round-trip"
4897            );
4898        }
4899
4900        /// A doc on `"tracks"` (typed BLE collection) reaches the typed
4901        /// BLE sink only — the gating wrapper declines the
4902        /// non-allow-list collection, so the lite-bridge sink stays
4903        /// empty. This is the load-bearing assertion that the gate
4904        /// prevents double emission on typed-BLE collections.
4905        #[tokio::test]
4906        async fn track_publish_reaches_only_typed_ble_sink() {
4907            let (fx, _h) = coexistence_fixture().await;
4908
4909            let doc = track_doc("ble-CAFE0001");
4910            fx.node.publish("tracks", doc).await.expect("publish track");
4911
4912            wait_for_any(&[&fx.ble_sink, &fx.lite_sink], 1).await;
4913
4914            let ble_frames = fx.ble_sink.snapshot();
4915            let lite_frames = fx.lite_sink.snapshot();
4916
4917            assert_eq!(
4918                ble_frames.len(),
4919                1,
4920                "typed BLE sink should see the track frame"
4921            );
4922            assert!(
4923                lite_frames.is_empty(),
4924                "lite-bridge sink MUST decline 'tracks' (not in \
4925                 LITE_BRIDGE_COLLECTIONS allow-list); got {} frames",
4926                lite_frames.len()
4927            );
4928        }
4929
4930        /// Origin-skip is independent per codec: a marker published
4931        /// with `origin = Some(BLE_LITE_BRIDGE)` (i.e. just received
4932        /// from BLE via the universal-Document path) must NOT
4933        /// re-emit through the lite-bridge sink. The typed BLE sink is
4934        /// unaffected — it would have declined the unknown collection
4935        /// regardless.
4936        #[tokio::test]
4937        async fn ble_lite_origin_marker_does_not_re_emit_to_lite_bridge() {
4938            let (fx, _h) = coexistence_fixture().await;
4939
4940            // Skip-origin doc.
4941            let skip_doc = marker_doc("marker-skip");
4942            fx.node
4943                .publish_with_origin("markers", skip_doc, Some(BLE_LITE_BRIDGE.to_string()))
4944                .await
4945                .expect("publish skip");
4946
4947            // Barrier doc with non-skip origin — when this lands at the
4948            // lite-bridge sink we know the prior skip-origin doc was
4949            // already processed (and correctly suppressed) by the
4950            // FIFO observer.
4951            let barrier_doc = marker_doc("marker-barrier");
4952            fx.node
4953                .publish_with_origin("markers", barrier_doc, Some("self".to_string()))
4954                .await
4955                .expect("publish barrier");
4956
4957            wait_for_any(&[&fx.lite_sink], 1).await;
4958
4959            let lite_frames = fx.lite_sink.snapshot();
4960            assert_eq!(
4961                lite_frames.len(),
4962                1,
4963                "lite-bridge sink MUST receive only the barrier doc; \
4964                 the BLE_LITE_BRIDGE-origin doc must be suppressed by \
4965                 origin-skip (echo-loop break)"
4966            );
4967            // Confirm the captured doc is the barrier, not the
4968            // skip-origin one — defends against an inverted-skip bug.
4969            let bytes = &lite_frames[0].2;
4970            let (_collection, decoded) =
4971                peat_mesh::transport::document_codec::decode_document(bytes)
4972                    .expect("decode envelope");
4973            assert_eq!(decoded.id.as_deref(), Some("marker-barrier"));
4974        }
4975
4976        /// Re-register after teardown succeeds — both translators get
4977        /// torn down + re-registered cleanly. Mirrors the
4978        /// unsubscribe → subscribe JNI flow with the lite-bridge
4979        /// branch active.
4980        #[tokio::test]
4981        async fn re_register_with_lite_bridge_after_unregister_succeeds() {
4982            let (fx, h1) = coexistence_fixture().await;
4983            drop(h1);
4984            fx.transport_manager
4985                .unregister_translator(BLE_LITE_BRIDGE)
4986                .await
4987                .expect("unregister lite-bridge");
4988            fx.transport_manager
4989                .unregister_translator("ble")
4990                .await
4991                .expect("unregister typed BLE");
4992
4993            // Second register pass on the same TransportManager must
4994            // succeed (no transport_id collision left over).
4995            let ble_translator = Arc::new(BleTranslator::with_defaults());
4996            let ble_sink = TaggedRecordingSink::new("ble");
4997            let ble_translator_dyn: Arc<dyn Translator> = ble_translator.clone();
4998            let ble_sink_dyn: Arc<dyn OutboundSink> = ble_sink.clone();
4999            fx.transport_manager
5000                .register_translator(
5001                    ble_translator_dyn,
5002                    ble_sink_dyn,
5003                    TranslatorRegistrationConfig::ble(),
5004                )
5005                .await
5006                .expect("re-register typed BLE");
5007
5008            let lite_translator: Arc<dyn Translator> = Arc::new(
5009                CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS),
5010            );
5011            let lite_sink = TaggedRecordingSink::new(BLE_LITE_BRIDGE);
5012            let lite_sink_dyn: Arc<dyn OutboundSink> = lite_sink.clone();
5013            fx.transport_manager
5014                .register_translator(
5015                    lite_translator,
5016                    lite_sink_dyn,
5017                    TranslatorRegistrationConfig::ble(),
5018                )
5019                .await
5020                .expect("re-register lite-bridge");
5021        }
5022    }
5023
5024    /// Wrapper-tier E2E tests for the poll API added for Dart/Flutter
5025    /// consumers.
5026    ///
5027    /// These tests exercise the full path through the `PeatNode` wrapper —
5028    /// `subscribe_poll` / `poll_changes`, `start_outbound_frames` /
5029    /// `poll_outbound_frames` / `stop_outbound_frames`, and
5030    /// `ingest_inbound_frame` — using `create_node` as the entry point, the
5031    /// same way Flutter consumers do. Each test is intentionally independent
5032    /// (separate temp dirs, separate nodes) so failures are local.
5033    #[cfg(all(feature = "sync", feature = "bluetooth"))]
5034    mod poll_api_wrapper_tests {
5035        use super::*;
5036
5037        fn test_cfg(storage_path: &str) -> NodeConfig {
5038            NodeConfig {
5039                app_id: "poll-wrapper-test".to_string(),
5040                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5041                bind_address: Some("127.0.0.1:0".to_string()),
5042                storage_path: storage_path.to_string(),
5043                transport: None,
5044            }
5045        }
5046
5047        /// `subscribe_poll` + `poll_changes` + `cancel` through the `PeatNode`
5048        /// wrapper.
5049        ///
5050        /// Creates a real node via `create_node`, subscribes with
5051        /// `subscribe_poll`, publishes a document via the mesh document
5052        /// layer (the path that actually
5053        /// triggers `subscribe_to_changes`), and verifies the change arrives
5054        /// through `poll_changes`. Also confirms the drain is
5055        /// idempotent and that `cancel` is safe to call multiple times.
5056        #[test]
5057        fn subscribe_poll_drain_and_cancel() {
5058            let tmp = tempfile::tempdir().unwrap();
5059            let node = create_node(test_cfg(tmp.path().to_str().unwrap())).expect("create_node");
5060
5061            let handle = node.subscribe_poll().expect("subscribe_poll");
5062
5063            // Publish through the mesh document layer — this feeds subscribe_to_changes().
5064            let mesh_node = Arc::clone(&node.node);
5065            node.runtime
5066                .block_on(publish_document_into_node(
5067                    &mesh_node,
5068                    "test",
5069                    r#"{"id":"doc-001","x":1}"#,
5070                ))
5071                .expect("publish_document_into_node");
5072
5073            // Give the spawned Tokio task time to pick up the broadcast.
5074            std::thread::sleep(std::time::Duration::from_millis(100));
5075
5076            let changes = handle.poll_changes();
5077            assert!(
5078                !changes.is_empty(),
5079                "poll_changes must return changes after publish_document_into_node"
5080            );
5081            assert!(
5082                changes.iter().any(|c| c.collection == "test"),
5083                "change must be for the 'test' collection; got: {changes:?}"
5084            );
5085
5086            // Drain is idempotent — second call returns nothing.
5087            assert!(
5088                handle.poll_changes().is_empty(),
5089                "second poll must be empty after drain"
5090            );
5091
5092            // cancel is safe to call repeatedly.
5093            handle.cancel();
5094            handle.cancel();
5095        }
5096
5097        /// `start_outbound_frames` → publish → `poll_outbound_frames` →
5098        /// `ingest_inbound_frame` → `stop_outbound_frames` → idempotent
5099        /// re-start.
5100        ///
5101        /// Covers the full wrapper path for the BLE poll API:
5102        /// - `start_outbound_frames` idempotency (second call is a no-op, not
5103        ///   an error)
5104        /// - A document published to "tracks" via the mesh layer produces an
5105        ///   outbound BLE frame visible through `poll_outbound_frames`
5106        /// - The polled frame can be fed into a second node via
5107        ///   `ingest_inbound_frame` and the decoded document appears in that
5108        ///   node's mesh store
5109        /// - `stop_outbound_frames` + `start_outbound_frames` re-registers the
5110        ///   translator without a duplicate-id collision
5111        #[test]
5112        fn outbound_frames_start_poll_ingest_stop_restart() {
5113            let tmp_a = tempfile::tempdir().unwrap();
5114            let tmp_b = tempfile::tempdir().unwrap();
5115            let node_a = create_node(test_cfg(tmp_a.path().to_str().unwrap())).expect("node_a");
5116            let node_b = create_node(test_cfg(tmp_b.path().to_str().unwrap())).expect("node_b");
5117
5118            // start is idempotent — second call must succeed, not error.
5119            node_a.start_outbound_frames().expect("start 1");
5120            node_a
5121                .start_outbound_frames()
5122                .expect("start 2 (idempotent no-op)");
5123
5124            // Publish a properly-structured tracks doc so BleTranslator can encode it.
5125            let tracks_json = r#"{
5126                "id": "track-wrap-001",
5127                "lat": 51.5, "lon": -0.1,
5128                "source_platform": "test-01",
5129                "hae": 10.0, "cep": 2.0,
5130                "classification": "a-f-G-U-C",
5131                "confidence": 0.9,
5132                "category": "friendly",
5133                "callsign": "ALPHA-1",
5134                "created_at": 1700000001000
5135            }"#;
5136            let mesh_a = Arc::clone(&node_a.node);
5137            node_a
5138                .runtime
5139                .block_on(publish_document_into_node(&mesh_a, "tracks", tracks_json))
5140                .expect("publish tracks");
5141
5142            // Poll with retries to allow the async fan-out observer to fire.
5143            let mut frames = Vec::new();
5144            for _ in 0..40 {
5145                frames = node_a.poll_outbound_frames();
5146                if !frames.is_empty() {
5147                    break;
5148                }
5149                std::thread::sleep(std::time::Duration::from_millis(25));
5150            }
5151            assert!(
5152                !frames.is_empty(),
5153                "outbound frames must appear after publishing to 'tracks'"
5154            );
5155            assert_eq!(frames[0].transport_id, "ble");
5156            assert_eq!(frames[0].collection, "tracks");
5157
5158            // Ingest on node_b — exercising the ingest_inbound_frame wrapper path.
5159            let doc_id = node_b
5160                .ingest_inbound_frame("tracks".to_string(), frames[0].bytes.clone())
5161                .expect("ingest_inbound_frame must not error")
5162                .expect("must return a doc_id for a valid tracks frame");
5163            assert!(!doc_id.is_empty(), "ingested doc_id must be non-empty");
5164
5165            // Document must be in node_b's mesh store.
5166            let stored = node_b
5167                .runtime
5168                .block_on(Arc::clone(&node_b.node).get("tracks", &doc_id))
5169                .expect("get must not error")
5170                .expect("ingested document must be in node_b's store");
5171            assert!(
5172                stored.fields.contains_key("lat"),
5173                "decoded track must carry lat field"
5174            );
5175
5176            // stop → re-start: translator must re-register without duplicate-id error.
5177            node_a.stop_outbound_frames();
5178            node_a
5179                .start_outbound_frames()
5180                .expect("re-start after stop must succeed");
5181            node_a.stop_outbound_frames(); // cleanup
5182        }
5183
5184        /// Receive-side counterpart for the universal-Document (`ble-lite`)
5185        /// codec — the path the production BLE pipe uses and that the typed
5186        /// `ingest_inbound_frame` test above does not exercise.
5187        ///
5188        /// Publishes to a `LITE_BRIDGE_COLLECTIONS` member the typed
5189        /// translator declines (`demo`), so it fans out solely as a `ble-lite`
5190        /// frame; captures that frame; ingests it on a second node via
5191        /// `PeatNode::ingest_inbound_lite_frame`; then asserts:
5192        /// (a) it converges into the receiver's store with the payload intact,
5193        /// (b) echo-suppression holds — the receiver does NOT re-emit it on
5194        ///     `ble-lite` (origin = `Some("ble-lite")` → fan-out skips the
5195        ///     originating transport). A regression here is the BLE echo storm.
5196        #[cfg(feature = "lite-bridge")]
5197        #[test]
5198        fn lite_outbound_poll_ingest_converges_without_echo() {
5199            let tmp_a = tempfile::tempdir().unwrap();
5200            let tmp_b = tempfile::tempdir().unwrap();
5201            let node_a = create_node(test_cfg(tmp_a.path().to_str().unwrap())).expect("node_a");
5202            let node_b = create_node(test_cfg(tmp_b.path().to_str().unwrap())).expect("node_b");
5203
5204            node_a.start_outbound_frames().expect("start a");
5205            node_b.start_outbound_frames().expect("start b");
5206
5207            // "demo" is on the lite-bridge allow-list AND declined by the typed
5208            // BleTranslator, so it fans out solely as a ble-lite frame.
5209            let demo_json = r#"{"id":"counter-demo-lite","inc":3,"dec":1,"by":"BRAVO"}"#;
5210            let mesh_a = Arc::clone(&node_a.node);
5211            node_a
5212                .runtime
5213                .block_on(publish_document_into_node(&mesh_a, "demo", demo_json))
5214                .expect("publish demo");
5215
5216            // Capture the ble-lite frame for the demo doc.
5217            let mut lite = None;
5218            for _ in 0..40 {
5219                if let Some(f) = node_a
5220                    .poll_outbound_frames()
5221                    .into_iter()
5222                    .find(|f| f.transport_id == "ble-lite" && f.collection == "demo")
5223                {
5224                    lite = Some(f);
5225                    break;
5226                }
5227                std::thread::sleep(std::time::Duration::from_millis(25));
5228            }
5229            let lite = lite.expect("a ble-lite frame must appear for the 'demo' doc");
5230
5231            // Drain anything node_b emitted before the ingest (expected: none).
5232            let _ = node_b.poll_outbound_frames();
5233
5234            // Ingest via the lite wrapper path on node_b.
5235            let doc_id = node_b
5236                .ingest_inbound_lite_frame("demo".to_string(), lite.bytes.clone())
5237                .expect("ingest_inbound_lite_frame must not error")
5238                .expect("must return a doc_id for a valid demo lite frame");
5239            assert!(!doc_id.is_empty(), "ingested doc_id must be non-empty");
5240
5241            // (a) Converged into node_b's store with the payload intact.
5242            let stored = node_b
5243                .runtime
5244                .block_on(Arc::clone(&node_b.node).get("demo", &doc_id))
5245                .expect("get must not error")
5246                .expect("ingested demo doc must be in node_b's store");
5247            assert_eq!(
5248                stored.fields.get("inc").and_then(|v| v.as_i64()),
5249                Some(3),
5250                "decoded demo doc must carry inc=3"
5251            );
5252            assert_eq!(
5253                stored.fields.get("by").and_then(|v| v.as_str()),
5254                Some("BRAVO"),
5255                "decoded demo doc must carry the 'by' field"
5256            );
5257
5258            // (b) Echo-suppression: node_b must NOT re-emit the just-ingested
5259            // doc on ble-lite. Any such frame in this window is the echo storm.
5260            let mut echoed = false;
5261            for _ in 0..16 {
5262                if node_b
5263                    .poll_outbound_frames()
5264                    .iter()
5265                    .any(|f| f.transport_id == "ble-lite" && f.collection == "demo")
5266                {
5267                    echoed = true;
5268                    break;
5269                }
5270                std::thread::sleep(std::time::Duration::from_millis(25));
5271            }
5272            assert!(
5273                !echoed,
5274                "ingested ble-lite doc must NOT be re-emitted on ble-lite \
5275                 (origin-skip / echo-suppression)"
5276            );
5277
5278            node_a.stop_outbound_frames();
5279            node_b.stop_outbound_frames();
5280        }
5281
5282        /// Direct coverage for the owning-handle store/clear semantics behind
5283        /// `set_global_node_handle` / `clearGlobalNodeHandleJni` (peat#978 UAF
5284        /// fix). Exercised against a LOCAL slot so it can't race the
5285        /// process-global `GLOBAL_NODE_HANDLE` other create-path tests touch.
5286        /// Asserts: store stashes a non-zero owning pointer (+1 strong ref);
5287        /// clear zeros the slot and drops exactly that one ref (no leak, no
5288        /// double-free).
5289        #[test]
5290        fn owning_node_slot_store_then_clear_drops_exactly_one_ref() {
5291            let tmp = tempfile::tempdir().unwrap();
5292            let node = create_node(test_cfg(tmp.path().to_str().unwrap())).expect("node");
5293            let slot = std::sync::Mutex::new(0i64);
5294
5295            let before = Arc::strong_count(&node);
5296            store_owning_node_in_slot(&slot, &node);
5297            assert_ne!(
5298                *slot.lock().unwrap(),
5299                0,
5300                "store must stash a non-zero owning pointer"
5301            );
5302            assert_eq!(
5303                Arc::strong_count(&node),
5304                before + 1,
5305                "store must add exactly one owning reference"
5306            );
5307
5308            clear_owning_node_slot(&slot);
5309            assert_eq!(*slot.lock().unwrap(), 0, "clear must zero the slot");
5310            assert_eq!(
5311                Arc::strong_count(&node),
5312                before,
5313                "clear must drop exactly the one stored reference (no leak/double-free)"
5314            );
5315        }
5316    }
5317
5318    /// Wrapped-vs-flat document-shape parsing (peat#978). Docs published
5319    /// through the node layer arrive wrapped as `{id, fields:{..},
5320    /// updated_at}`; legacy `storage_backend` writes are flat.
5321    /// `parse_node/cell/command_json` must read both shapes identically —
5322    /// the contract `LITE_BRIDGE_COLLECTIONS` now depends on for
5323    /// nodes/cells/commands to round-trip over BLE. The lite-bridge E2E
5324    /// test uses the flat `demo` shape, so it exercised only the
5325    /// fallback-to-root branch; these lock in the wrapped-`fields` branch.
5326    mod doc_shape_parse_tests {
5327        use super::*;
5328
5329        fn wrap(fields_json: &str) -> String {
5330            String::from(r#"{"id":"x","fields":"#)
5331                + fields_json
5332                + r#","updated_at":{"secs_since_epoch":1730000000,"nanos_since_epoch":0}}"#
5333        }
5334
5335        #[test]
5336        fn parse_node_json_wrapped_equals_flat() {
5337            let flat = r#"{"node_type":"peat-flutter","name":"Kilo","status":"ACTIVE","readiness":1.0,"capabilities":["comms","leader"],"last_heartbeat":1730000000000}"#;
5338            let a = parse_node_json("n1", flat).expect("flat parse");
5339            let b = parse_node_json("n1", &wrap(flat)).expect("wrapped parse");
5340            assert_eq!(
5341                b.name, "Kilo",
5342                "wrapped name must come from fields, not the id"
5343            );
5344            assert_eq!(b.name, a.name);
5345            assert_eq!(b.node_type, a.node_type);
5346            assert_eq!(b.capabilities, a.capabilities);
5347            assert_eq!(
5348                b.capabilities,
5349                vec!["comms".to_string(), "leader".to_string()]
5350            );
5351            assert_eq!(b.last_heartbeat, a.last_heartbeat);
5352            assert_eq!(b.last_heartbeat, 1730000000000);
5353        }
5354
5355        #[test]
5356        fn parse_cell_json_wrapped_equals_flat() {
5357            let flat = r#"{"name":"Alpha Cell","status":"ACTIVE","node_count":2,"capabilities":["comms"],"leader_id":"n1","last_update":1730000000000}"#;
5358            let a = parse_cell_json("alpha", flat).expect("flat parse");
5359            let b = parse_cell_json("alpha", &wrap(flat)).expect("wrapped parse");
5360            assert_eq!(b.name, "Alpha Cell");
5361            assert_eq!(b.node_count, 2);
5362            assert_eq!(b.node_count, a.node_count);
5363            assert_eq!(b.leader_id, a.leader_id);
5364            assert_eq!(b.capabilities, a.capabilities);
5365        }
5366
5367        #[test]
5368        fn parse_command_json_wrapped_equals_flat() {
5369            let flat = r#"{"command_type":"WATER_REQUEST","target_id":"leader","parameters":{"quantity":5,"from":"Kilo"},"priority":1,"status":"PENDING","originator":"n1","created_at":1730000000000,"last_update":1730000000000}"#;
5370            let a = parse_command_json("req-1", flat).expect("flat parse");
5371            let b = parse_command_json("req-1", &wrap(flat)).expect("wrapped parse");
5372            assert_eq!(b.command_type, "WATER_REQUEST");
5373            assert_eq!(b.command_type, a.command_type);
5374            assert_eq!(b.originator, a.originator);
5375            assert_eq!(b.target_id, a.target_id);
5376            // parameters round-trips as the same JSON-object string in both shapes.
5377            assert_eq!(b.parameters, a.parameters);
5378        }
5379    }
5380
5381    #[cfg(feature = "sync")]
5382    mod blob_tests {
5383        use super::*;
5384
5385        /// Generate a synthetic test JPEG with a color gradient and a label.
5386        /// Synthetic "JPEG-like" payload for blob-transfer tests. Starts with
5387        /// the SOI marker (FF D8) and ends with EOI (FF D9) so the test
5388        /// assertions (`bytes[0]==0xFF`, `bytes[1]==0xD8`, `len > 100`,
5389        /// `len < 80_000`) all hold; the bytes in between are deterministic
5390        /// per (label, hue_shift) so each call produces a distinct blob
5391        /// hash. The blob-transfer path under test is byte-agnostic — using
5392        /// real JPEG encoding would pull the `image` crate's ~40 transitive
5393        /// dependencies into the workspace just for a synthetic test
5394        /// payload, which trips cargo-vet for no functional benefit.
5395        fn generate_test_image(label: &str, width: u32, height: u32, hue_shift: u8) -> Vec<u8> {
5396            let body_len = (width as usize * height as usize) / 4;
5397            let mut buf = Vec::with_capacity(body_len + label.len() + 8);
5398            buf.extend_from_slice(&[0xFF, 0xD8]); // SOI
5399            buf.extend_from_slice(label.as_bytes());
5400            buf.push(hue_shift);
5401            buf.extend(std::iter::repeat(hue_shift.wrapping_mul(3)).take(body_len));
5402            buf.extend_from_slice(&[0xFF, 0xD9]); // EOI
5403            buf
5404        }
5405
5406        fn test_node_config(storage_path: &str) -> NodeConfig {
5407            NodeConfig {
5408                app_id: "blob-test".to_string(),
5409                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5410                bind_address: Some("127.0.0.1:0".to_string()),
5411                storage_path: storage_path.to_string(),
5412                transport: None,
5413            }
5414        }
5415
5416        #[test]
5417        fn test_blob_put_get_local_roundtrip() {
5418            let tmp = tempfile::tempdir().unwrap();
5419            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5420                .expect("create_node failed");
5421
5422            node.enable_blob_transfer(None)
5423                .expect("enable_blob_transfer failed");
5424
5425            assert!(
5426                node.blob_endpoint_id().is_some(),
5427                "blob endpoint should be initialized"
5428            );
5429
5430            let test_data = b"SKUNK-1 image chip placeholder";
5431            let hash = node
5432                .blob_put(test_data, "image/jpeg")
5433                .expect("blob_put failed");
5434            assert!(!hash.is_empty(), "hash should be non-empty");
5435
5436            assert!(
5437                node.blob_exists_locally(&hash),
5438                "blob should exist locally after put"
5439            );
5440
5441            let retrieved = node.blob_get(&hash).expect("blob_get failed");
5442            assert_eq!(retrieved, test_data, "retrieved bytes must match original");
5443        }
5444
5445        #[test]
5446        fn test_blob_get_nonexistent_returns_error() {
5447            let tmp = tempfile::tempdir().unwrap();
5448            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5449                .expect("create_node failed");
5450
5451            node.enable_blob_transfer(None)
5452                .expect("enable_blob_transfer failed");
5453
5454            let fake_hash = "0000000000000000000000000000000000000000000000000000000000000000";
5455            assert!(
5456                !node.blob_exists_locally(fake_hash),
5457                "nonexistent hash should not be local"
5458            );
5459
5460            let result = node.blob_get(fake_hash);
5461            assert!(result.is_err(), "fetching nonexistent blob should error");
5462        }
5463
5464        #[test]
5465        fn test_blob_transfer_disabled_errors() {
5466            let tmp = tempfile::tempdir().unwrap();
5467            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5468                .expect("create_node failed");
5469
5470            // Don't call enable_blob_transfer — methods should return errors
5471            assert!(node.blob_endpoint_id().is_none());
5472            assert!(node.blob_put(b"data", "text/plain").is_err());
5473            assert!(node.blob_get("abc").is_err());
5474            assert!(!node.blob_exists_locally("abc"));
5475        }
5476
5477        #[test]
5478        fn test_blob_cross_node_transfer() {
5479            let tmp_a = tempfile::tempdir().unwrap();
5480            let tmp_b = tempfile::tempdir().unwrap();
5481
5482            let node_a = create_node(NodeConfig {
5483                app_id: "blob-xfer-test".to_string(),
5484                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5485                bind_address: Some("127.0.0.1:0".to_string()),
5486                storage_path: tmp_a.path().to_str().unwrap().to_string(),
5487                transport: None,
5488            })
5489            .expect("create node A");
5490
5491            let node_b = create_node(NodeConfig {
5492                app_id: "blob-xfer-test".to_string(),
5493                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5494                bind_address: Some("127.0.0.1:0".to_string()),
5495                storage_path: tmp_b.path().to_str().unwrap().to_string(),
5496                transport: None,
5497            })
5498            .expect("create node B");
5499
5500            // Enable blob transfer on both with ephemeral ports
5501            node_a
5502                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5503                .expect("enable blob A");
5504            node_b
5505                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5506                .expect("enable blob B");
5507
5508            let a_endpoint_id = node_a.blob_endpoint_id().expect("A blob endpoint");
5509            let a_addr = node_a.blob_bound_addr().expect("A bound addr");
5510
5511            // Register A as a blob peer on B
5512            node_b
5513                .blob_add_peer(&a_endpoint_id, &a_addr)
5514                .expect("add peer");
5515
5516            // Put blob on A
5517            let test_data = b"cross-node image chip test payload 1234567890";
5518            let hash = node_a.blob_put(test_data, "image/jpeg").expect("put on A");
5519
5520            // Fetch from B — should pull from A via iroh-blobs downloader
5521            let retrieved = node_b.blob_get(&hash).expect("get from B");
5522            assert_eq!(
5523                retrieved, test_data,
5524                "cross-node blob transfer: bytes must match"
5525            );
5526        }
5527
5528        #[test]
5529        fn test_e2e_contact_report_with_image_chip() {
5530            // End-to-end: sim node publishes a contact report (TrackUpdate)
5531            // with an embedded image chip blob hash. Tablet node syncs the
5532            // document and fetches the blob by hash. Validates the full
5533            // demo chain: mesh-leader → Iroh doc sync → tablet receives
5534            // track → tablet fetches image via blob transfer.
5535
5536            let tmp_sim = tempfile::tempdir().unwrap();
5537            let tmp_tablet = tempfile::tempdir().unwrap();
5538
5539            // Create sim node (mesh-leader stand-in)
5540            let sim = create_node(NodeConfig {
5541                app_id: "e2e-contact-test".to_string(),
5542                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5543                bind_address: Some("127.0.0.1:0".to_string()),
5544                storage_path: tmp_sim.path().to_str().unwrap().to_string(),
5545                transport: None,
5546            })
5547            .expect("create sim node");
5548
5549            // Create tablet node
5550            let tablet = create_node(NodeConfig {
5551                app_id: "e2e-contact-test".to_string(),
5552                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5553                bind_address: Some("127.0.0.1:0".to_string()),
5554                storage_path: tmp_tablet.path().to_str().unwrap().to_string(),
5555                transport: None,
5556            })
5557            .expect("create tablet node");
5558
5559            // Enable blob transfer on both
5560            sim.enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5561                .expect("sim blob");
5562            tablet
5563                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5564                .expect("tablet blob");
5565
5566            // Wire blob peers
5567            let sim_blob_id = sim.blob_endpoint_id().unwrap();
5568            let sim_blob_addr = sim.blob_bound_addr().unwrap();
5569            tablet
5570                .blob_add_peer(&sim_blob_id, &sim_blob_addr)
5571                .expect("tablet add sim as blob peer");
5572
5573            // Connect doc-sync peers so the track document propagates
5574            let sim_sync_id = sim.node_id();
5575            let sim_sync_addr = format!("{:?}", sim.iroh_transport.endpoint_addr());
5576            // For doc sync, connect tablet → sim via Iroh transport
5577            let sim_peer = PeerInfo {
5578                name: "sim".to_string(),
5579                node_id: sim_sync_id.clone(),
5580                addresses: vec![],
5581                relay_url: None,
5582            };
5583            // Use the runtime to connect
5584            let sim_clone = Arc::clone(&sim);
5585            let tablet_clone = Arc::clone(&tablet);
5586            tablet.runtime.block_on(async {
5587                tablet_clone
5588                    .iroh_transport
5589                    .connect_peer(&peat_protocol::network::PeerInfo {
5590                        name: "sim".to_string(),
5591                        node_id: sim_sync_id,
5592                        addresses: vec![sim_clone
5593                            .iroh_transport
5594                            .endpoint_addr()
5595                            .addrs
5596                            .iter()
5597                            .next()
5598                            .map(|a| format!("{}", a))
5599                            .unwrap_or_default()],
5600                        relay_url: None,
5601                    })
5602                    .await
5603                    .ok();
5604            });
5605
5606            // 1. Sim creates an image chip blob
5607            let fake_jpeg = b"\xFF\xD8\xFF\xE0fake-jpeg-contact-report-image-chip-data";
5608            let image_hash = sim.blob_put(fake_jpeg, "image/jpeg").expect("sim blob put");
5609
5610            // 2. Sim publishes a contact report (TrackUpdate) to the tracks collection
5611            let track_json = serde_json::json!({
5612                "id": "red-track-1",
5613                "source_node": "sensor-node-3",
5614                "source_model": "FLIR Vue Pro R 640",
5615                "model_version": "1.0",
5616                "cell_id": "company-CHARLIE",
5617                "lat": 32.655,
5618                "lon": -117.245,
5619                "heading": 0.0,
5620                "speed": 7.7,
5621                "classification": "a-h-S",
5622                "confidence": 0.82,
5623                "category": "VESSEL",
5624                "attributes": {
5625                    "callsign": "SKUNK-1",
5626                    "speed_kts": "15",
5627                    "vehicle_class": "fast attack craft",
5628                    "reporter": "sensor-node-3",
5629                    "distance_to_reporter_m": "800",
5630                    "image_chip_hash": &image_hash,
5631                },
5632                "last_update": std::time::SystemTime::now()
5633                    .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64,
5634            });
5635
5636            // Write to the tracks collection on the sim node
5637            let sim_backend = &sim.storage_backend;
5638            let tracks_coll = sim_backend.collection("tracks");
5639            tracks_coll
5640                .upsert("red-track-1", track_json.to_string().into_bytes())
5641                .expect("sim upsert track");
5642
5643            // 3. Wait for doc sync (give Iroh a moment to propagate)
5644            std::thread::sleep(std::time::Duration::from_secs(2));
5645
5646            // 4. Tablet reads the tracks collection
5647            let tablet_tracks = tablet_clone.storage_backend.collection("tracks");
5648            let track_doc = tablet_tracks.scan().expect("tablet scan tracks");
5649
5650            // The track may or may not have synced in 2s — this is the
5651            // realistic case. If it synced, validate the full chain.
5652            // If not, the blob transfer tests above already prove the
5653            // primitive works; this test extends coverage to the doc layer.
5654            if let Some((_id, data)) = track_doc.into_iter().find(|(id, _)| id == "red-track-1") {
5655                let parsed: serde_json::Value = serde_json::from_slice(&data).expect("valid JSON");
5656                assert_eq!(parsed["source_node"], "sensor-node-3");
5657                assert_eq!(parsed["classification"], "a-h-S");
5658                assert_eq!(parsed["attributes"]["callsign"], "SKUNK-1");
5659                assert_eq!(parsed["attributes"]["image_chip_hash"], image_hash);
5660
5661                // 5. Tablet fetches the image chip blob by hash
5662                let chip_hash = parsed["attributes"]["image_chip_hash"]
5663                    .as_str()
5664                    .expect("hash is string");
5665                let chip_bytes = tablet.blob_get(chip_hash).expect("tablet blob get");
5666                assert_eq!(
5667                    chip_bytes, fake_jpeg,
5668                    "image chip bytes must match across mesh"
5669                );
5670
5671                eprintln!("E2E PASS: contact report + image chip transferred through mesh");
5672            } else {
5673                // Doc sync didn't complete in 2s — not a failure of our code,
5674                // just Iroh mesh formation timing. The blob tests above prove
5675                // the primitive. Log and pass.
5676                eprintln!(
5677                    "E2E SKIP: doc sync didn't complete in 2s (blob transfer \
5678                     validated separately). Re-run if you want full chain coverage."
5679                );
5680            }
5681        }
5682
5683        #[test]
5684        fn test_blob_transfer_with_synthetic_image() {
5685            let tmp_a = tempfile::tempdir().unwrap();
5686            let tmp_b = tempfile::tempdir().unwrap();
5687
5688            let node_a = create_node(NodeConfig {
5689                app_id: "img-xfer-test".to_string(),
5690                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5691                bind_address: Some("127.0.0.1:0".to_string()),
5692                storage_path: tmp_a.path().to_str().unwrap().to_string(),
5693                transport: None,
5694            })
5695            .expect("create node A");
5696
5697            let node_b = create_node(NodeConfig {
5698                app_id: "img-xfer-test".to_string(),
5699                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5700                bind_address: Some("127.0.0.1:0".to_string()),
5701                storage_path: tmp_b.path().to_str().unwrap().to_string(),
5702                transport: None,
5703            })
5704            .expect("create node B");
5705
5706            node_a
5707                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5708                .expect("enable A");
5709            node_b
5710                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5711                .expect("enable B");
5712
5713            let a_id = node_a.blob_endpoint_id().unwrap();
5714            let a_addr = node_a.blob_bound_addr().unwrap();
5715            node_b.blob_add_peer(&a_id, &a_addr).expect("add peer");
5716
5717            // Generate 4 keyframe images (matching the demo's progression stages)
5718            let images = vec![
5719                (
5720                    "distant",
5721                    generate_test_image("SKUNK-1 DISTANT", 160, 120, 40),
5722                ),
5723                (
5724                    "approach",
5725                    generate_test_image("SKUNK-1 APPROACH", 160, 120, 80),
5726                ),
5727                ("close", generate_test_image("SKUNK-1 CLOSE", 160, 120, 160)),
5728                ("id", generate_test_image("SKUNK-1 ID", 160, 120, 220)),
5729            ];
5730
5731            for (label, jpeg_bytes) in &images {
5732                assert!(jpeg_bytes.len() > 100, "{} should be a real JPEG", label);
5733                assert!(
5734                    jpeg_bytes.len() < 80_000,
5735                    "{} should be under 80KB (got {})",
5736                    label,
5737                    jpeg_bytes.len()
5738                );
5739                // JPEG magic bytes
5740                assert_eq!(jpeg_bytes[0], 0xFF);
5741                assert_eq!(jpeg_bytes[1], 0xD8);
5742            }
5743
5744            // Put all 4 on node A, fetch from node B
5745            let mut hashes = Vec::new();
5746            for (label, jpeg_bytes) in &images {
5747                let hash = node_a
5748                    .blob_put(jpeg_bytes, "image/jpeg")
5749                    .unwrap_or_else(|e| panic!("put {label}: {e}"));
5750                hashes.push((label.to_string(), hash));
5751            }
5752
5753            for (label, hash) in &hashes {
5754                let fetched = node_b
5755                    .blob_get(hash)
5756                    .unwrap_or_else(|e| panic!("get {label}: {e}"));
5757                let original = &images.iter().find(|(l, _)| l == label).unwrap().1;
5758                assert_eq!(
5759                    fetched.len(),
5760                    original.len(),
5761                    "{}: fetched size must match",
5762                    label
5763                );
5764                assert_eq!(
5765                    fetched, *original,
5766                    "{}: fetched bytes must match original",
5767                    label
5768                );
5769            }
5770
5771            eprintln!(
5772                "IMAGE TRANSFER PASS: 4 synthetic JPEGs transferred cross-node ({} total bytes)",
5773                images.iter().map(|(_, b)| b.len()).sum::<usize>()
5774            );
5775        }
5776    }
5777
5778    /// Surface-tier tests for the two new public entry points added
5779    /// for peat-mesh#138 M4 (peat#879): `PeatNode::endpoint_socket_addr`
5780    /// and `PeatNode::get_document`. Both are wrapped by JNI symbols
5781    /// (`endpointSocketAddrJni`, `getDocumentJni`) that the two-
5782    /// instance instrumented test suite in peat-mesh/android-tests
5783    /// will consume in M4b. Per the surface-tier E2E rule these need
5784    /// in-crate tests independent of that downstream consumer.
5785    #[cfg(feature = "sync")]
5786    mod m4_endpoint_and_get_document_tests {
5787        use super::*;
5788
5789        fn test_node_config(storage_path: &str) -> NodeConfig {
5790            NodeConfig {
5791                app_id: "m4-test".to_string(),
5792                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5793                bind_address: Some("127.0.0.1:0".to_string()),
5794                storage_path: storage_path.to_string(),
5795                transport: None,
5796            }
5797        }
5798
5799        /// `endpoint_socket_addr` on a freshly-bound node returns a
5800        /// string that round-trips through `SocketAddr::parse` and
5801        /// carries a non-zero port. This is the contract M4b's
5802        /// instrumented test relies on when it feeds the returned
5803        /// string back into `connectPeerJni` on the other instance.
5804        #[test]
5805        fn endpoint_socket_addr_returns_parseable_loopback_addr() {
5806            let tmp = tempfile::tempdir().unwrap();
5807            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5808                .expect("create_node failed");
5809
5810            let addr_str = node
5811                .endpoint_socket_addr()
5812                .expect("a bound node must report at least one IP address");
5813
5814            let parsed: std::net::SocketAddr = addr_str.parse().unwrap_or_else(|e| {
5815                panic!("endpoint_socket_addr returned '{addr_str}' which doesn't parse as SocketAddr: {e}")
5816            });
5817            assert!(
5818                parsed.port() > 0,
5819                "port must be nonzero for a bound socket, got {parsed}"
5820            );
5821        }
5822
5823        /// Publish a doc through the document layer, then read it
5824        /// back through the same layer. Locks in the round-trip
5825        /// contract that `publishDocumentJni` + `getDocumentJni`
5826        /// expose: both go through `peat_mesh::Node`'s document API,
5827        /// not the older raw-bytes Collection path used by typed
5828        /// helpers like `publish_node`.
5829        ///
5830        /// The in-process variant locks in the publish+get half on a
5831        /// single instance; cross-node sync is exercised by M4b on
5832        /// real devices in peat-mesh/android-tests.
5833        #[test]
5834        fn document_layer_round_trip_publish_then_get() {
5835            let tmp = tempfile::tempdir().unwrap();
5836            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5837                .expect("create_node failed");
5838
5839            let collection = "markers";
5840            let doc_id = "M-RT-1";
5841            let body = format!(r#"{{"id":"{doc_id}","name":"alpha","severity":3}}"#);
5842
5843            let mesh_node = Arc::clone(&node.node);
5844            let returned_id = node
5845                .runtime
5846                .block_on(publish_document_into_node(&mesh_node, collection, &body))
5847                .expect("publish_document_into_node");
5848            assert_eq!(returned_id, doc_id);
5849
5850            let fetched = node
5851                .runtime
5852                .block_on(mesh_node.get(collection, &doc_id.to_string()))
5853                .expect("get must not Err")
5854                .expect("doc must be present on the publishing node");
5855
5856            // Body content must round-trip; assert on the two fields
5857            // M4b's Kotlin test pins. The published id is hoisted to
5858            // Document::id; assert separately.
5859            assert_eq!(
5860                fetched.id.as_deref(),
5861                Some(doc_id),
5862                "published id must round-trip through Document::id"
5863            );
5864            assert_eq!(
5865                fetched.fields.get("name").and_then(|v| v.as_str()),
5866                Some("alpha")
5867            );
5868            assert_eq!(
5869                fetched.fields.get("severity").and_then(|v| v.as_i64()),
5870                Some(3)
5871            );
5872        }
5873
5874        /// Surface-tier coverage for `getDocumentJni`'s JSON
5875        /// serialization path (peat#879 QA round 2). The struct-
5876        /// level round-trip test above exercises storage; this one
5877        /// exercises the extracted `serialize_document_for_get_jni`
5878        /// helper that produces the exact bytes the JNI returns —
5879        /// covering the id-reinsertion, field-iteration, and
5880        /// `to_string()` encoding the QA reviewer flagged as
5881        /// untested.
5882        #[test]
5883        fn jni_serializer_reinserts_id_alongside_fields() {
5884            // Publish through the same path the JNI consumer takes,
5885            // read back via Node::get, then run the JNI's serializer
5886            // and assert on the JSON the consumer would actually see.
5887            let tmp = tempfile::tempdir().unwrap();
5888            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5889                .expect("create_node failed");
5890
5891            let collection = "markers";
5892            let doc_id = "M-RT-1";
5893            let body = format!(r#"{{"id":"{doc_id}","name":"alpha","severity":3}}"#);
5894
5895            let mesh_node = Arc::clone(&node.node);
5896            let _ = node
5897                .runtime
5898                .block_on(publish_document_into_node(&mesh_node, collection, &body))
5899                .expect("publish");
5900
5901            let fetched = node
5902                .runtime
5903                .block_on(mesh_node.get(collection, &doc_id.to_string()))
5904                .expect("get must not Err")
5905                .expect("doc must be present");
5906
5907            // Serialize via the exact helper getDocumentJni uses.
5908            let json = serialize_document_for_get_jni(&fetched);
5909            let parsed: serde_json::Value =
5910                serde_json::from_str(&json).expect("JNI output must parse as JSON");
5911
5912            // The Kotlin consumer expects: a plain object with id +
5913            // every other field. Pin each field shape including the
5914            // reinserted id (the QA-flagged regression surface).
5915            assert!(
5916                parsed.is_object(),
5917                "output must be a JSON object, got {parsed:?}"
5918            );
5919            assert_eq!(parsed["id"], doc_id, "id must be reinserted");
5920            assert_eq!(parsed["name"], "alpha");
5921            assert_eq!(parsed["severity"], 3);
5922            // Field count: id + name + severity — no extras.
5923            assert_eq!(
5924                parsed.as_object().unwrap().len(),
5925                3,
5926                "unexpected extra fields in JNI serialization: {parsed}"
5927            );
5928        }
5929
5930        /// Boundary: a Document with no `id` (a write path that
5931        /// didn't go through publish-with-explicit-id) serializes
5932        /// without an `"id"` key — never as `"id": null`. This
5933        /// matches the consumer contract that `id` is present iff
5934        /// the document had one assigned.
5935        #[test]
5936        fn jni_serializer_omits_id_when_none() {
5937            let doc = peat_mesh::sync::Document {
5938                id: None,
5939                fields: {
5940                    let mut m = std::collections::HashMap::new();
5941                    m.insert("k".to_string(), serde_json::Value::String("v".into()));
5942                    m
5943                },
5944                updated_at: std::time::SystemTime::now(),
5945            };
5946
5947            let json = serialize_document_for_get_jni(&doc);
5948            let parsed: serde_json::Value = serde_json::from_str(&json).expect("parseable JSON");
5949
5950            assert!(
5951                parsed.get("id").is_none(),
5952                "expected id absent (not null) when Document::id is None, got {json}"
5953            );
5954            assert_eq!(parsed["k"], "v");
5955        }
5956
5957        /// `peat_mesh::Node::get` on a never-published key returns
5958        /// `Ok(None)`. The `getDocumentJni` wrapper maps this to a
5959        /// null jstring — test-readable as "not yet converged"
5960        /// rather than "store failed". Symmetry with
5961        /// `document_layer_round_trip_publish_then_get`.
5962        #[test]
5963        fn document_layer_get_returns_none_for_missing_doc() {
5964            let tmp = tempfile::tempdir().unwrap();
5965            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5966                .expect("create_node failed");
5967
5968            let mesh_node = Arc::clone(&node.node);
5969            let result = node
5970                .runtime
5971                .block_on(mesh_node.get("markers", &"never-published".to_string()))
5972                .expect("get must not Err");
5973            assert!(
5974                result.is_none(),
5975                "expected None for a never-published doc, got {result:?}"
5976            );
5977        }
5978    }
5979
5980    /// Round-trip tests for the `NodeInfo` JSON wire schema.
5981    ///
5982    /// Locks in the symmetry contract between `parse_node_json`
5983    /// (storage → struct) and `serialize_node_json` (struct →
5984    /// storage), and the parallel JNI inline encode/decode in
5985    /// `Java_..._publishNodeJni` / `Java_..._getNodesJni`. The
5986    /// pre-2026-05-08 schema dropped `battery_percent` and `heart_rate`
5987    /// silently across the FFI boundary: Kotlin published them, Rust
5988    /// didn't extract them, the receiver's `getNodesJni` didn't
5989    /// emit them, the Kotlin parser saw them as `null`, and operator
5990    /// cards on remote peers showed no battery/heart indicators.
5991    /// Without a Rust-side test the bug compile-cleaned and only
5992    /// surfaced via three-device on-hardware UAT. Each assertion below
5993    /// corresponds to one optional field; future schema additions
5994    /// should add a parallel assertion + bump
5995    /// `every_optional_field_round_trips_through_storage` so the
5996    /// matrix stays exhaustive.
5997    #[cfg(feature = "sync")]
5998    mod node_tests {
5999        use super::*;
6000
6001        fn fixture(battery: Option<i32>, heart: Option<i32>) -> NodeInfo {
6002            NodeInfo {
6003                id: "ANDROID-fixture".to_string(),
6004                node_type: "SOLDIER".to_string(),
6005                name: "HOBO".to_string(),
6006                status: NodeStatus::Active,
6007                lat: 33.71576,
6008                lon: -84.41152,
6009                hae: Some(305.0),
6010                readiness: 1.0,
6011                capabilities: vec!["PLI".to_string()],
6012                cell_id: Some("BRAVO".to_string()),
6013                battery_percent: battery,
6014                heart_rate: heart,
6015                last_heartbeat: 1_700_000_000_000,
6016            }
6017        }
6018
6019        /// `serialize_node_json` → `parse_node_json` is the
6020        /// path `put_node` / `get_nodes` traverse via the
6021        /// AutomergeBackend storage. Every field a `NodeInfo`
6022        /// carries today must round-trip; if a future field is added
6023        /// to the struct without being added to either codec function,
6024        /// this assertion catches it before the FFI consumer does.
6025        #[test]
6026        fn every_optional_field_round_trips_through_storage_codec() {
6027            let original = fixture(Some(85), Some(72));
6028            let json = serialize_node_json(&original).expect("serialize");
6029            let parsed = parse_node_json(&original.id, &json).expect("parse");
6030
6031            assert_eq!(parsed.id, original.id);
6032            assert_eq!(parsed.node_type, original.node_type);
6033            assert_eq!(parsed.name, original.name);
6034            assert_eq!(parsed.lat, original.lat);
6035            assert_eq!(parsed.lon, original.lon);
6036            assert_eq!(parsed.hae, original.hae);
6037            assert_eq!(parsed.readiness, original.readiness);
6038            assert_eq!(parsed.capabilities, original.capabilities);
6039            assert_eq!(parsed.cell_id, original.cell_id);
6040            assert_eq!(parsed.battery_percent, original.battery_percent);
6041            assert_eq!(parsed.heart_rate, original.heart_rate);
6042            assert_eq!(parsed.last_heartbeat, original.last_heartbeat);
6043        }
6044
6045        /// `battery_percent: None` must serialize to a JSON `null` (or
6046        /// absent) and parse back to `None` — not silently fill 0,
6047        /// which the dropdown UI would render as "battery dead" on
6048        /// nodes that simply have no battery sensor (fixed
6049        /// sensors, demo nodes).
6050        #[test]
6051        fn battery_none_round_trips_as_none() {
6052            let original = fixture(None, None);
6053            let json = serialize_node_json(&original).expect("serialize");
6054            let parsed = parse_node_json(&original.id, &json).expect("parse");
6055
6056            assert!(parsed.battery_percent.is_none());
6057            assert!(parsed.heart_rate.is_none());
6058        }
6059
6060        /// Schema is forward-compatible: a JSON written by a newer
6061        /// peer that adds a field we don't know yet must still parse,
6062        /// dropping the unknown key. Conversely, a JSON written by an
6063        /// older peer that lacks `battery_percent` / `heart_rate`
6064        /// must parse with those fields as `None` rather than failing.
6065        #[test]
6066        fn legacy_json_without_battery_or_heart_parses_with_none() {
6067            let legacy_json = serde_json::json!({
6068                "node_type": "SOLDIER",
6069                "name": "LEGACY-PEER",
6070                "status": "ACTIVE",
6071                "lat": 33.71,
6072                "lon": -84.41,
6073                "hae": null,
6074                "readiness": 1.0,
6075                "capabilities": ["PLI"],
6076                "cell_id": "BRAVO",
6077                "last_heartbeat": 1_700_000_000_000_i64,
6078            })
6079            .to_string();
6080
6081            let parsed =
6082                parse_node_json("LEGACY-PEER", &legacy_json).expect("legacy json must parse");
6083
6084            assert!(parsed.battery_percent.is_none());
6085            assert!(parsed.heart_rate.is_none());
6086            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6087        }
6088
6089        /// `put_node` → `get_nodes` is the actual storage
6090        /// path the JNI layer exposes. Bypassing the codec helpers
6091        /// and going through `node.put_node(...)` exercises the
6092        /// AutomergeBackend serialize/scan/deserialize loop end-to-end
6093        /// — which is exactly where peat#832 (BLE-bridged tracks
6094        /// losing body fields) demonstrated the codec helpers can
6095        /// look correct in isolation while still dropping data
6096        /// across the storage round-trip.
6097        #[test]
6098        fn put_node_get_nodes_preserves_battery_and_heart() {
6099            let tmp = tempfile::tempdir().unwrap();
6100            let node = create_node(NodeConfig {
6101                app_id: "node-rt-test".to_string(),
6102                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
6103                bind_address: Some("127.0.0.1:0".to_string()),
6104                storage_path: tmp.path().to_str().unwrap().to_string(),
6105                transport: None,
6106            })
6107            .expect("create_node");
6108
6109            let original = fixture(Some(85), Some(72));
6110            node.put_node(original.clone()).expect("put_node");
6111
6112            let listed = node.get_nodes().expect("get_nodes");
6113            let found = listed
6114                .iter()
6115                .find(|p| p.id == original.id)
6116                .expect("published node must appear in get_nodes");
6117
6118            assert_eq!(
6119                found.battery_percent,
6120                Some(85),
6121                "battery_percent dropped between put_node and get_nodes"
6122            );
6123            assert_eq!(
6124                found.heart_rate,
6125                Some(72),
6126                "heart_rate dropped between put_node and get_nodes"
6127            );
6128            assert_eq!(found.cell_id.as_deref(), Some("BRAVO"));
6129        }
6130
6131        /// JNI inline-parser path: the publish surface consumers
6132        /// actually hit. Builds a JSON envelope shaped exactly like
6133        /// a typical self-position broadcaster would publish, runs
6134        /// it through the same `parse_node_publish_json` helper
6135        /// `publishNodeJni` invokes, and verifies battery + heart
6136        /// land in the resulting `NodeInfo`. Locks the duplicated
6137        /// codec — pre-2026-05-08 this was inlined inside the JNI
6138        /// function and unit tests couldn't reach it, which is how
6139        /// peat#835's bug class (silent field drop on the publish
6140        /// path) shipped without a CI signal.
6141        #[test]
6142        fn publish_json_inline_parser_extracts_battery_and_heart() {
6143            let json = r#"{
6144                "id": "ANDROID-abc123",
6145                "name": "HOBO",
6146                "node_type": "SOLDIER",
6147                "lat": 33.71576,
6148                "lon": -84.41152,
6149                "hae": 305.0,
6150                "status": "ACTIVE",
6151                "capabilities": ["PLI"],
6152                "readiness": 1.0,
6153                "cell_id": "BRAVO",
6154                "battery_percent": 85,
6155                "heart_rate": 72
6156            }"#;
6157
6158            let parsed = parse_node_publish_json(json).expect("parse");
6159
6160            assert_eq!(parsed.id, "ANDROID-abc123");
6161            assert_eq!(parsed.battery_percent, Some(85));
6162            assert_eq!(parsed.heart_rate, Some(72));
6163            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6164            assert!(parsed.capabilities.contains(&"PLI".to_string()));
6165        }
6166
6167        /// Reject an empty `id` at the publish boundary — the id is
6168        /// the storage key downstream. The pre-extraction inline code
6169        /// returned 0/JNI_FALSE on this case; the test pins the
6170        /// equivalent error contract.
6171        #[test]
6172        fn publish_json_rejects_missing_id() {
6173            let json = r#"{"name":"HOBO","node_type":"SOLDIER","lat":33.7,"lon":-84.4}"#;
6174            assert!(parse_node_publish_json(json).is_err());
6175
6176            let empty_id = r#"{"id":"","name":"HOBO","lat":33.7,"lon":-84.4}"#;
6177            assert!(parse_node_publish_json(empty_id).is_err());
6178        }
6179
6180        /// Out-of-range numeric values clamp to the logical end of
6181        /// the range rather than silently dropping to `None`. The
6182        /// silent-`None`-on-overflow shape is the same bug class
6183        /// peat#835 exists to lock — a pathological 2³² battery
6184        /// becoming "no sensor" is visually identical to the
6185        /// legitimate None case, which is exactly the data-loss
6186        /// failure mode the PR exists to prevent.
6187        #[test]
6188        fn battery_and_heart_clamp_out_of_range_numbers() {
6189            // Battery above 100 clamps to 100.
6190            let high = serde_json::json!(9999);
6191            assert_eq!(parse_battery_percent(&high), Some(100));
6192
6193            // Negative battery clamps to 0.
6194            let neg = serde_json::json!(-50);
6195            assert_eq!(parse_battery_percent(&neg), Some(0));
6196
6197            // i64::MAX clamps to 100 — the silent-None-on-overflow
6198            // case the pre-clamp `as_i64().and_then(i32::try_from)`
6199            // chain produced None for. After clamp, fail-safe.
6200            let huge = serde_json::json!(i64::MAX);
6201            assert_eq!(parse_battery_percent(&huge), Some(100));
6202
6203            // Heart rate above 250 clamps to 250 (max plausible BPM).
6204            let bpm_high = serde_json::json!(500);
6205            assert_eq!(parse_heart_rate(&bpm_high), Some(250));
6206
6207            // Heart rate below 0 clamps to 0; legitimate low BPM
6208            // (bradycardia, asystole) passes through unchanged. The
6209            // 30-floor was lowered in round-3 — see
6210            // `heart_rate_preserves_bradycardia_below_30`.
6211            let bpm_neg = serde_json::json!(-50);
6212            assert_eq!(parse_heart_rate(&bpm_neg), Some(0));
6213            let bpm_low_real = serde_json::json!(10);
6214            assert_eq!(parse_heart_rate(&bpm_low_real), Some(10));
6215        }
6216
6217        /// Non-numeric values (publisher serialization bug, hostile
6218        /// peer, schema drift) parse as `None` rather than coercing.
6219        /// We accept "no sensor" but reject silent type coercion —
6220        /// `"85"` as a JSON string is a publisher bug, not a value
6221        /// to interpret.
6222        #[test]
6223        fn battery_and_heart_reject_non_numeric() {
6224            let s = serde_json::json!("85");
6225            assert!(parse_battery_percent(&s).is_none());
6226            assert!(parse_heart_rate(&s).is_none());
6227
6228            let null = serde_json::Value::Null;
6229            assert!(parse_battery_percent(&null).is_none());
6230            assert!(parse_heart_rate(&null).is_none());
6231
6232            let arr = serde_json::json!([85]);
6233            assert!(parse_battery_percent(&arr).is_none());
6234        }
6235
6236        /// Forward-compat: a peer running a future schema that adds
6237        /// fields we don't know about must still parse cleanly,
6238        /// silently dropping the unknowns. Locks the existing
6239        /// `unwrap_or` / `optional`-style behavior so a future
6240        /// stricter parser doesn't regress this on accident.
6241        #[test]
6242        fn parse_silently_drops_unknown_future_fields() {
6243            let json = r#"{
6244                "node_type": "SOLDIER",
6245                "name": "FUTURE-PEER",
6246                "status": "ACTIVE",
6247                "lat": 33.71,
6248                "lon": -84.41,
6249                "readiness": 1.0,
6250                "capabilities": ["PLI"],
6251                "cell_id": "BRAVO",
6252                "battery_percent": 90,
6253                "last_heartbeat": 1700000000000,
6254
6255                "future_v2_field_one": "should be ignored",
6256                "future_v2_struct": { "nested": 42 },
6257                "future_v2_array": [1, 2, 3]
6258            }"#;
6259
6260            let parsed =
6261                parse_node_json("FUTURE-PEER", json).expect("future-shaped json must parse");
6262            assert_eq!(parsed.battery_percent, Some(90));
6263            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6264            // No assertion about the unknown fields — they're
6265            // intentionally dropped on the floor. The test exists to
6266            // keep us honest if anyone tries to switch to a stricter
6267            // `serde_json::from_str::<TypedStruct>` shape.
6268        }
6269
6270        /// **Round-3 / peat#835 review item P2-1**: float-typed
6271        /// numeric wire payloads must not silently drop. The
6272        /// pre-round-3 implementation used `as_i64()?` which returns
6273        /// `None` for any JSON Number stored as float — a Kotlin
6274        /// publisher serializing `battery_percent` as `Double`
6275        /// (`85.0`), or any node whose JSON serializer renders
6276        /// integers with a trailing `.0`, would silently lose the
6277        /// field. That's the same data-loss bug class peat#835 was
6278        /// opened to lock in the first place.
6279        #[test]
6280        fn battery_accepts_float_form() {
6281            assert_eq!(parse_battery_percent(&serde_json::json!(85.0)), Some(85));
6282            // Fractional rounds to nearest.
6283            assert_eq!(parse_battery_percent(&serde_json::json!(85.7)), Some(86));
6284            assert_eq!(parse_battery_percent(&serde_json::json!(85.4)), Some(85));
6285            // Float still clamps.
6286            assert_eq!(parse_battery_percent(&serde_json::json!(150.0)), Some(100));
6287            assert_eq!(parse_battery_percent(&serde_json::json!(-10.5)), Some(0));
6288        }
6289
6290        #[test]
6291        fn heart_rate_accepts_float_form() {
6292            assert_eq!(parse_heart_rate(&serde_json::json!(72.0)), Some(72));
6293            assert_eq!(parse_heart_rate(&serde_json::json!(72.6)), Some(73));
6294            assert_eq!(parse_heart_rate(&serde_json::json!(300.0)), Some(250));
6295        }
6296
6297        /// Bradycardia: athletic resting HR can dip into the 20s,
6298        /// asystole reads as 0. Round-3 lowered the floor from 30 to
6299        /// 0 so the UI gets the truth and can decide what to flag.
6300        /// The pre-round-3 floor of 30 silently rounded these up,
6301        /// hiding the very signal a heart-rate indicator should
6302        /// surface.
6303        #[test]
6304        fn heart_rate_preserves_bradycardia_below_30() {
6305            assert_eq!(parse_heart_rate(&serde_json::json!(25)), Some(25));
6306            assert_eq!(parse_heart_rate(&serde_json::json!(0)), Some(0));
6307            // Negative still clamps to 0 — sensor noise / signed-int
6308            // serialization bug.
6309            assert_eq!(parse_heart_rate(&serde_json::json!(-5)), Some(0));
6310        }
6311
6312        /// **Round-3**: extracted emit-side codec
6313        /// `serialize_nodes_get_json` mirrors the parse-side
6314        /// extraction (`parse_node_publish_json`). Without the
6315        /// extraction, the inline `getNodesJni` json! macro was a
6316        /// duplicated codec the test suite couldn't reach — same
6317        /// drift class peat#835 originally exposed on the parse side.
6318        /// This test pins the emit shape end-to-end.
6319        #[test]
6320        fn serialize_nodes_get_json_round_trips_through_parser() {
6321            let original = NodeInfo {
6322                id: "ANDROID-emit".to_string(),
6323                node_type: "SOLDIER".to_string(),
6324                name: "EMIT-TEST".to_string(),
6325                status: NodeStatus::Active,
6326                lat: 33.71576,
6327                lon: -84.41152,
6328                hae: Some(305.0),
6329                readiness: 1.0,
6330                capabilities: vec!["PLI".to_string()],
6331                cell_id: Some("BRAVO".to_string()),
6332                battery_percent: Some(85),
6333                heart_rate: Some(72),
6334                last_heartbeat: 1_700_000_000_000,
6335            };
6336
6337            let emitted = serialize_nodes_get_json(std::slice::from_ref(&original));
6338            let arr: Vec<serde_json::Value> = serde_json::from_str(&emitted).expect("array");
6339            assert_eq!(arr.len(), 1);
6340
6341            // Parse the emitted JSON back through the storage parser
6342            // (the path `getNodes` consumers' downstream Kotlin
6343            // parsers mirror) and assert symmetry.
6344            let obj_str = serde_json::to_string(&arr[0]).expect("obj");
6345            let parsed = parse_node_json(&original.id, &obj_str).expect("parse");
6346            assert_eq!(parsed.battery_percent, Some(85));
6347            assert_eq!(parsed.heart_rate, Some(72));
6348            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6349            assert_eq!(parsed.last_heartbeat, 1_700_000_000_000);
6350        }
6351
6352        /// **Round-3 P3-1**: when a publisher provides a
6353        /// `last_heartbeat` on the wire, the publish-path parser
6354        /// honors it instead of stamping `Utc::now()`. Resolves the
6355        /// doc-comment-vs-behavior tension: the field doc-comment
6356        /// describes a "0 means stale" convention that the publish
6357        /// path was actively preventing from ever shipping.
6358        #[test]
6359        fn publish_json_honors_wire_last_heartbeat() {
6360            let supplied: i64 = 1_700_000_123_456;
6361            let json = format!(
6362                r#"{{
6363                    "id": "ANDROID-replay",
6364                    "name": "REPLAY",
6365                    "node_type": "SOLDIER",
6366                    "lat": 0.0, "lon": 0.0,
6367                    "status": "ACTIVE",
6368                    "last_heartbeat": {}
6369                }}"#,
6370                supplied
6371            );
6372            let parsed = parse_node_publish_json(&json).expect("parse");
6373            assert_eq!(parsed.last_heartbeat, supplied);
6374        }
6375
6376        /// And: when the wire omits `last_heartbeat`, fall back to
6377        /// `now()` (preserving back-compat with publishers that don't
6378        /// stamp the field).
6379        #[test]
6380        fn publish_json_stamps_now_when_last_heartbeat_absent() {
6381            let before = chrono::Utc::now().timestamp_millis();
6382            let json = r#"{
6383                "id": "ANDROID-no-stamp",
6384                "name": "FRESH",
6385                "node_type": "SOLDIER",
6386                "lat": 0.0, "lon": 0.0,
6387                "status": "ACTIVE"
6388            }"#;
6389            let parsed = parse_node_publish_json(json).expect("parse");
6390            let after = chrono::Utc::now().timestamp_millis();
6391            assert!(
6392                parsed.last_heartbeat >= before && parsed.last_heartbeat <= after,
6393                "last_heartbeat ({}) should be in [{}, {}]",
6394                parsed.last_heartbeat,
6395                before,
6396                after
6397            );
6398        }
6399
6400        /// **Round-4 P1**: wire `last_heartbeat: 0` is the documented
6401        /// stale-record sentinel per the `NodeInfo` field doc;
6402        /// must round-trip unchanged. Round-3's `> 0` filter
6403        /// inverted this contract, silently replacing the
6404        /// stale-marker with `Utc::now()`. Test pins the corrected
6405        /// behavior so the regression can't recur.
6406        #[test]
6407        fn publish_json_preserves_wire_last_heartbeat_zero_as_stale_marker() {
6408            let json = r#"{
6409                "id": "ANDROID-stale",
6410                "name": "STALE",
6411                "node_type": "SOLDIER",
6412                "lat": 0.0, "lon": 0.0,
6413                "status": "ACTIVE",
6414                "last_heartbeat": 0
6415            }"#;
6416            let parsed = parse_node_publish_json(json).expect("parse");
6417            assert_eq!(
6418                parsed.last_heartbeat, 0,
6419                "wire `last_heartbeat: 0` must pass through as the stale-record sentinel"
6420            );
6421        }
6422
6423        /// **Round-4 P1 / P2**: smallest non-zero positive timestamp
6424        /// (`1`) and a small value (`12345`) both pass through as-is.
6425        /// These are the boundary values around the prior `> 0`
6426        /// filter; round-4 dropped the filter, so all positive values
6427        /// short of the future-skew clamp must round-trip.
6428        #[test]
6429        fn publish_json_preserves_small_positive_last_heartbeat() {
6430            for wire in [1_i64, 12_345, 1_700_000_000_000] {
6431                let json = format!(
6432                    r#"{{"id":"ANDROID-{w}","name":"X","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{w}}}"#,
6433                    w = wire,
6434                );
6435                let parsed = parse_node_publish_json(&json).expect("parse");
6436                assert_eq!(
6437                    parsed.last_heartbeat, wire,
6438                    "wire `{}` must round-trip",
6439                    wire
6440                );
6441            }
6442        }
6443
6444        /// **Round-4 P2 #4**: clock-skew injection guard. A peer with
6445        /// a far-future-skewed clock can publish `i64::MAX` (or any
6446        /// timestamp beyond `now() + 60s` grace); the parser caps to
6447        /// `now()` so downstream staleness UI can't be gamed into
6448        /// "always fresh." Negative values pass through (very stale,
6449        /// but not absurd).
6450        #[test]
6451        fn publish_json_clamps_far_future_last_heartbeat_to_now() {
6452            let json = r#"{
6453                "id": "ANDROID-malicious",
6454                "name": "MALICIOUS",
6455                "node_type": "SOLDIER",
6456                "lat": 0.0, "lon": 0.0,
6457                "status": "ACTIVE",
6458                "last_heartbeat": 9223372036854775807
6459            }"#;
6460            let before = chrono::Utc::now().timestamp_millis();
6461            let parsed = parse_node_publish_json(json).expect("parse");
6462            let after = chrono::Utc::now().timestamp_millis();
6463            assert!(
6464                parsed.last_heartbeat >= before && parsed.last_heartbeat <= after,
6465                "i64::MAX must clamp to now(), got {}",
6466                parsed.last_heartbeat
6467            );
6468        }
6469
6470        /// **Round-5**: negative `last_heartbeat` collapses to the
6471        /// stale-marker (`0`) rather than passing through. Round-4
6472        /// let negatives through with a doc-comment claim that
6473        /// downstream Long arithmetic produced a "sensible large
6474        /// positive age" — that was wrong: `now - i64::MIN`
6475        /// overflows, and the Kotlin `Long` subtraction silently
6476        /// wraps. Pin the corrected behavior so a malicious peer
6477        /// publishing `last_heartbeat: i64::MIN` can't game the
6478        /// staleness UI in the opposite direction from the
6479        /// `i64::MAX` case.
6480        #[test]
6481        fn publish_json_clamps_negative_last_heartbeat_to_zero() {
6482            for wire in [-1_i64, -1_700_000_000_000, i64::MIN] {
6483                let json = format!(
6484                    r#"{{"id":"ANDROID-neg-{w}","name":"NEG","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{w}}}"#,
6485                    w = wire,
6486                );
6487                let parsed = parse_node_publish_json(&json)
6488                    .unwrap_or_else(|e| panic!("wire {} must parse: {:?}", wire, e));
6489                assert_eq!(
6490                    parsed.last_heartbeat, 0,
6491                    "negative wire `{}` must collapse to stale-marker `0`",
6492                    wire
6493                );
6494            }
6495        }
6496
6497        /// Wire timestamp within the 60-second future-grace window
6498        /// passes through (legitimate clock drift between mobile
6499        /// devices on unrelated networks). Beyond grace, clamp.
6500        #[test]
6501        fn publish_json_within_grace_window_passes_through_then_clamps_beyond() {
6502            let now = chrono::Utc::now().timestamp_millis();
6503            // 30 s in the future — within grace.
6504            let in_grace = now + 30_000;
6505            let json = format!(
6506                r#"{{"id":"ANDROID-grace","name":"G","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{}}}"#,
6507                in_grace
6508            );
6509            let parsed = parse_node_publish_json(&json).expect("parse");
6510            assert_eq!(parsed.last_heartbeat, in_grace);
6511
6512            // 5 minutes in the future — beyond 60 s grace, clamp.
6513            let beyond = chrono::Utc::now().timestamp_millis() + 5 * 60 * 1000;
6514            let json2 = format!(
6515                r#"{{"id":"ANDROID-skew","name":"S","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{}}}"#,
6516                beyond
6517            );
6518            let parsed2 = parse_node_publish_json(&json2).expect("parse");
6519            assert!(
6520                parsed2.last_heartbeat < beyond,
6521                "5min-future must clamp ({} should be << {})",
6522                parsed2.last_heartbeat,
6523                beyond
6524            );
6525        }
6526
6527        /// **Round-4 P3 #7**: float rounding mode is half-away-from-zero
6528        /// per `f64::round()`. Pin the contract so a future refactor to
6529        /// `round_ties_even` (banker's) doesn't silently change the
6530        /// emitted i32 by ±1 for half-values.
6531        #[test]
6532        fn battery_percent_rounds_halves_away_from_zero() {
6533            assert_eq!(parse_battery_percent(&serde_json::json!(85.5)), Some(86));
6534            assert_eq!(parse_battery_percent(&serde_json::json!(84.5)), Some(85));
6535            // 0.5 rounds to 1, not 0 (half-away-from-zero, not
6536            // banker's-rounding).
6537            assert_eq!(parse_battery_percent(&serde_json::json!(0.5)), Some(1));
6538        }
6539
6540        /// **Round-4 P3 #9**: forward-compat for the publish parser.
6541        /// Mirror of `parse_silently_drops_unknown_future_fields`
6542        /// for the storage parser; both share the
6543        /// `serde_json::Value`-indexing pattern but the contract
6544        /// should be locked separately so a future refactor of
6545        /// either to a typed `serde::Deserialize` doesn't regress
6546        /// half the surface unnoticed.
6547        #[test]
6548        fn publish_json_silently_drops_unknown_future_fields() {
6549            let json = r#"{
6550                "id": "ANDROID-future",
6551                "name": "FUTURE",
6552                "node_type": "SOLDIER",
6553                "lat": 33.71, "lon": -84.41,
6554                "status": "ACTIVE",
6555                "battery_percent": 90,
6556
6557                "future_v2_field_one": "should be ignored",
6558                "future_v2_struct": { "nested": 42 },
6559                "future_v2_array": [1, 2, 3]
6560            }"#;
6561            let parsed = parse_node_publish_json(json).expect("future-shaped publish must parse");
6562            assert_eq!(parsed.battery_percent, Some(90));
6563            assert_eq!(parsed.id, "ANDROID-future");
6564        }
6565    }
6566
6567    /// End-to-end round-trip tests for the track storage path that
6568    /// `Java_..._ingestPositionJni` and `Java_..._getTracksJni` expose
6569    /// to consumer plugins.
6570    ///
6571    /// peat#832 (open as of 2026-05-08) reports the BLE-bridged tracks
6572    /// surface every body field at `parse_track_json`'s `unwrap_or`
6573    /// default (lat/lon=0.0, classification="a-u-G", confidence=0.5,
6574    /// source_node="unknown") even though `ingest_position_via_translator`
6575    /// publishes valid coordinates. The hypothesis the issue records:
6576    /// the writer publishes via `peat_mesh::Node::publish_with_origin`
6577    /// (Document API → Automerge map storage), but the reader uses
6578    /// `AutomergeBackend::collection().scan()` which returns bytes the
6579    /// reader assumes are flat JSON. The two APIs disagree on the
6580    /// on-disk shape, so body fields don't survive the round-trip.
6581    ///
6582    /// Existing `ingest_position_tests` (line ~2520) wires
6583    /// `peat_mesh::Node` against an `InMemoryBackend` from peat-mesh —
6584    /// that backend doesn't carry the AutomergeBackend / Collection
6585    /// scan asymmetry, so it has no way to reproduce the bug. The
6586    /// tests below use `create_node()` (the same factory the JNI
6587    /// surface uses) so the AutomergeBackend disagreement is in scope.
6588    ///
6589    /// `ingest_position_via_translator_then_get_tracks_preserves_body`
6590    /// is the regression gate: pre-fix it failed deterministically,
6591    /// post-fix it locks the symmetry. The dev-team-owns-validation
6592    /// memory captures the broader pattern.
6593    #[cfg(all(feature = "sync", feature = "bluetooth"))]
6594    mod track_tests {
6595        use super::*;
6596        use peat_protocol::sync::ble_translation::{
6597            value_to_mesh_document, BlePosition, BleTranslator,
6598        };
6599
6600        /// Test fixture that holds both the constructed node and the
6601        /// tempdir backing its storage. Bind both via `let _node_fx =
6602        /// ingest_position_test_node();` and let the drop order do the
6603        /// right thing — `Drop for PeatNode` (and its inner
6604        /// `AutomergeStore`) runs first, then the tempdir's
6605        /// `Drop for TempDir` removes the on-disk directory.
6606        ///
6607        /// Earlier this fixture used `std::mem::forget(tmp)` on the
6608        /// `TempDir` with a comment claiming "Tempdirs are nuked at
6609        /// process exit anyway" — that's wrong: `tempfile::TempDir`
6610        /// cleanup runs in its `Drop` impl, which `mem::forget` skips,
6611        /// and process exit doesn't trigger OS-level `/tmp` cleanup.
6612        /// Re-running `cargo test track_tests` locally accumulated
6613        /// `/tmp/.tmpXXXXXX` directories until reboot.
6614        struct TrackFixture {
6615            node: Arc<PeatNode>,
6616            // Field is read via the binding lifetime (Drop runs after
6617            // `node`), not by the test body. `dead_code` would lint
6618            // otherwise — `_tmp` makes the role explicit.
6619            #[allow(dead_code)]
6620            _tmp: tempfile::TempDir,
6621        }
6622
6623        fn ingest_position_test_node() -> TrackFixture {
6624            let tmp = tempfile::tempdir().expect("tempdir");
6625            let path = tmp.path().to_str().expect("tempdir path utf-8").to_string();
6626
6627            let node = create_node(NodeConfig {
6628                app_id: "track-rt-test".to_string(),
6629                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
6630                bind_address: Some("127.0.0.1:0".to_string()),
6631                storage_path: path,
6632                transport: None,
6633            })
6634            .expect("create_node");
6635
6636            TrackFixture { node, _tmp: tmp }
6637        }
6638
6639        /// Sanity check the **flat-JSON** path: `put_track` →
6640        /// `serialize_track_json` → `coll.upsert(json_bytes)` → `coll.scan()`
6641        /// → `parse_track_json` → `get_tracks`. Both writer and reader
6642        /// use the same flat-JSON shape, so this should round-trip
6643        /// today. If this ever fails, the asymmetry has spread to
6644        /// even the typed-API path.
6645        #[test]
6646        fn put_track_get_tracks_preserves_body() {
6647            let fx = ingest_position_test_node();
6648            let pn = &fx.node;
6649
6650            let original = TrackInfo {
6651                id: "manual-001".to_string(),
6652                source_node: "ANDROID-tablet".to_string(),
6653                cell_id: Some("BRAVO".to_string()),
6654                formation_id: None,
6655                lat: 33.71576,
6656                lon: -84.41152,
6657                hae: Some(305.0),
6658                cep: Some(5.0),
6659                heading: Some(87.5),
6660                speed: Some(1.2),
6661                classification: "a-f-G-U-C-I".to_string(),
6662                confidence: 0.9,
6663                category: TrackCategory::Person,
6664                created_at: 1_700_000_000_000,
6665                last_update: 1_700_000_000_000,
6666                attributes: std::collections::HashMap::new(),
6667            };
6668
6669            pn.put_track(original.clone()).expect("put_track");
6670            let listed = pn.get_tracks().expect("get_tracks");
6671            let found = listed
6672                .iter()
6673                .find(|t| t.id == "manual-001")
6674                .expect("track must appear");
6675
6676            assert!(
6677                (found.lat - original.lat).abs() < 1e-9,
6678                "lat dropped via put_track/get_tracks: got {}",
6679                found.lat
6680            );
6681            assert!(
6682                (found.lon - original.lon).abs() < 1e-9,
6683                "lon dropped via put_track/get_tracks: got {}",
6684                found.lon
6685            );
6686            assert_eq!(found.cell_id.as_deref(), Some("BRAVO"));
6687            assert_eq!(found.source_node, original.source_node);
6688            assert_eq!(found.classification, original.classification);
6689        }
6690
6691        /// peat#832 regression gate: the **BLE-bridged path** that
6692        /// `ingestPositionJni` exercises on every BLE peer's position
6693        /// advert. Writer goes through `Node::publish_with_origin`
6694        /// (Document API); the original reader went through
6695        /// `AutomergeBackend::collection().scan()` (flat-JSON API),
6696        /// and the two storage-API namespaces disagreed — every body
6697        /// field came back as a `parse_track_json` `unwrap_or`
6698        /// default (lat/lon=0.0, source_node="unknown",
6699        /// classification="a-u-G"). Fix routes `get_tracks` through
6700        /// `Node::query` so writer and reader share the Document API,
6701        /// and `put_track` was migrated to `Node::publish` to keep
6702        /// the typed-API path consistent. If either path breaks, this
6703        /// test catches it before on-device UAT does.
6704        #[test]
6705        fn ingest_position_via_translator_then_get_tracks_preserves_body() {
6706            let fx = ingest_position_test_node();
6707            let pn = &fx.node;
6708            let translator = BleTranslator::with_defaults();
6709
6710            const PERIPHERAL: u32 = 0xCAFE_0001;
6711            let position = BlePosition {
6712                latitude: 33.71576,
6713                longitude: -84.41152,
6714                altitude: Some(305.0),
6715                accuracy: Some(5.0),
6716            };
6717            let value = translator.position_to_track_in_cell(
6718                &position,
6719                PERIPHERAL,
6720                Some("SCOUT-CAFE"),
6721                Some("BRAVO"),
6722            );
6723            let doc = value_to_mesh_document(value);
6724
6725            pn.runtime.block_on(async {
6726                pn.node
6727                    .publish_with_origin(
6728                        translator.tracks_collection(),
6729                        doc,
6730                        Some("ble".to_string()),
6731                    )
6732                    .await
6733                    .expect("publish_with_origin");
6734            });
6735
6736            let tracks = pn.get_tracks().expect("get_tracks");
6737            let found = tracks
6738                .iter()
6739                .find(|t| t.id.contains("CAFE0001"))
6740                .expect("BLE-bridged track must appear in get_tracks output");
6741
6742            assert!(
6743                (found.lat - 33.71576).abs() < 1e-4,
6744                "peat#832: lat dropped — got {} (expected ~33.71576)",
6745                found.lat
6746            );
6747            assert!(
6748                (found.lon - (-84.41152)).abs() < 1e-4,
6749                "peat#832: lon dropped — got {} (expected ~-84.41152)",
6750                found.lon
6751            );
6752            assert_eq!(
6753                found.cell_id.as_deref(),
6754                Some("BRAVO"),
6755                "peat#832: cell_id dropped"
6756            );
6757            assert!(
6758                !found.source_node.is_empty() && found.source_node != "unknown",
6759                "peat#832: source_node reverted to default — got {:?}",
6760                found.source_node
6761            );
6762            assert_ne!(
6763                found.classification, "a-u-G",
6764                "peat#832: classification reverted to default a-u-G"
6765            );
6766        }
6767
6768        /// Single-id read path: `get_track(id)` migrated to
6769        /// `Node::get` along with `get_tracks` (PR #836). Without
6770        /// this test the per-id path was silent in the regression
6771        /// suite — same bug class could re-emerge on it without a
6772        /// signal.
6773        #[test]
6774        fn ingest_position_then_get_track_single_id_preserves_body() {
6775            let fx = ingest_position_test_node();
6776            let pn = &fx.node;
6777            let translator = BleTranslator::with_defaults();
6778
6779            const PERIPHERAL: u32 = 0xCAFE_0002;
6780            let position = BlePosition {
6781                latitude: 33.71576,
6782                longitude: -84.41152,
6783                altitude: Some(305.0),
6784                accuracy: Some(5.0),
6785            };
6786            let value = translator.position_to_track_in_cell(
6787                &position,
6788                PERIPHERAL,
6789                Some("SCOUT-ID-2"),
6790                Some("BRAVO"),
6791            );
6792            let track_id = value
6793                .get("id")
6794                .and_then(|v| v.as_str())
6795                .expect("translator stamps id")
6796                .to_string();
6797            let doc = value_to_mesh_document(value);
6798
6799            pn.runtime.block_on(async {
6800                pn.node
6801                    .publish_with_origin(
6802                        translator.tracks_collection(),
6803                        doc,
6804                        Some("ble".to_string()),
6805                    )
6806                    .await
6807                    .expect("publish_with_origin");
6808            });
6809
6810            let single = pn
6811                .get_track(&track_id)
6812                .expect("get_track")
6813                .expect("track must exist for known id");
6814
6815            assert!((single.lat - 33.71576).abs() < 1e-4);
6816            assert!((single.lon - (-84.41152)).abs() < 1e-4);
6817            assert_eq!(single.cell_id.as_deref(), Some("BRAVO"));
6818            assert_eq!(single.id, track_id);
6819        }
6820
6821        /// Pre-fix-shape entries (written via `coll.upsert(json_bytes)`
6822        /// before this PR) won't decode through `Node::query`'s
6823        /// `serde_json::from_slice::<Document>` reader and are silently
6824        /// dropped. Codifies the migration story: devices upgrading to
6825        /// a new `libpeat_ffi.so` will *not* see pre-fix tracks until
6826        /// the BLE peer republishes (every ~5 s in normal operation),
6827        /// but they also won't crash on the stale bytes.
6828        ///
6829        /// Test writes a fake old-shape entry directly through the
6830        /// untyped Collection surface, then calls `get_tracks` and
6831        /// asserts (a) it doesn't error, (b) the legacy entry is
6832        /// invisible. `put_track` itself can't be used here because
6833        /// PR #836 migrated it to `Node::publish` (correctly), so
6834        /// reaching the old shape requires going through
6835        /// `storage_backend.collection().upsert(...)` directly.
6836        #[test]
6837        fn pre_fix_flat_json_entries_are_silently_dropped_not_crashed() {
6838            let fx = ingest_position_test_node();
6839            let pn = &fx.node;
6840
6841            // Old-shape: flat JSON of the body, written via the
6842            // untyped Collection upsert (the pre-#836 `put_track`
6843            // codepath). Bytes are intentionally well-formed JSON so
6844            // any *parse* error that fires would be in the Document
6845            // deserialization step, not in JSON tokenization.
6846            let legacy = serde_json::json!({
6847                "source_node": "ble-DEAD0001",
6848                "lat": 33.0,
6849                "lon": -84.0,
6850                "classification": "a-f-G-U-C-I",
6851                "confidence": 0.9,
6852                "category": "PERSON",
6853                "created_at": 1_700_000_000_000_i64,
6854                "last_update": 1_700_000_000_000_i64,
6855            })
6856            .to_string()
6857            .into_bytes();
6858
6859            // `pn.storage_backend` is `Arc<AutomergeBackend>` from
6860            // `peat_protocol::storage`; its `StorageBackend::collection`
6861            // returns the untyped `Arc<dyn Collection>` whose
6862            // `upsert(doc_id, Vec<u8>)` is the pre-#836 write path the
6863            // bug originally lived in.
6864            let coll = pn.storage_backend.collection(collections::TRACKS);
6865            coll.upsert("legacy-track-DEAD0001", legacy)
6866                .expect("legacy upsert must succeed");
6867
6868            // get_tracks must not error.
6869            let listed = pn.get_tracks().expect("get_tracks must not panic");
6870
6871            // The legacy entry must NOT appear via the Node::query
6872            // path — its bytes don't decode as a Document, so it's
6873            // silently dropped per the documented migration semantics.
6874            assert!(
6875                listed.iter().all(|t| t.id != "legacy-track-DEAD0001"),
6876                "pre-fix legacy entry must be silently invisible after migration: {:?}",
6877                listed.iter().map(|t| &t.id).collect::<Vec<_>>()
6878            );
6879        }
6880    }
6881
6882    /// Marker tombstone schema. peat-mesh's fan-out skips
6883    /// `ChangeEvent::Removed` today (Slice-2 work), so deletion of
6884    /// a synced marker is communicated via a `_deleted: true`
6885    /// sentinel ridden on the Updated channel. Consumers publish a
6886    /// tombstone on deletion and filter `_deleted: true` entries out
6887    /// of "current markers" views on render. These tests pin the
6888    /// wire shape so a future schema change has to pass through the
6889    /// test gate first.
6890    mod marker_tombstone {
6891        use super::*;
6892
6893        /// A minimum-viable tombstone publish carries `uid` +
6894        /// `_deleted: true` only — the publisher omits type/lat/lon
6895        /// to keep the BLE frame small. The parser must accept this
6896        /// shape (placeholders for the absent geo fields), set
6897        /// `deleted = true`, and round-trip cleanly.
6898        #[test]
6899        fn parse_minimal_tombstone() {
6900            let json = r#"{"uid":"abc-123","_deleted":true,"ts":1700000000000}"#;
6901            let m = parse_marker_publish_json("", json).expect("minimal tombstone parses");
6902            assert!(m.deleted, "deleted flag set");
6903            assert_eq!(m.uid, "abc-123");
6904            assert_eq!(m.ts, 1700000000000);
6905        }
6906
6907        /// A live (non-tombstone) marker still requires type/lat/lon.
6908        /// Drops `_deleted` from the body — the parser must default
6909        /// `deleted = false` and enforce the required-fields contract
6910        /// it enforced before the tombstone shape was added.
6911        #[test]
6912        fn parse_live_marker_requires_geo() {
6913            let no_type = r#"{"uid":"x","lat":1.0,"lon":2.0}"#;
6914            assert!(parse_marker_publish_json("", no_type).is_err());
6915
6916            let no_lat = r#"{"uid":"x","type":"a-f-G","lon":2.0}"#;
6917            assert!(parse_marker_publish_json("", no_lat).is_err());
6918
6919            let no_lon = r#"{"uid":"x","type":"a-f-G","lat":1.0}"#;
6920            assert!(parse_marker_publish_json("", no_lon).is_err());
6921
6922            let ok = r#"{"uid":"x","type":"a-f-G","lat":1.0,"lon":2.0}"#;
6923            let m = parse_marker_publish_json("", ok).expect("live marker parses");
6924            assert!(!m.deleted);
6925        }
6926
6927        /// `serialize_marker_json` round-trips a tombstone. The
6928        /// `_deleted: true` key MUST appear in the output (otherwise
6929        /// peers receiving the doc see a normal-looking marker and
6930        /// re-render it after a refresh tick — the deletion would
6931        /// "un-do" itself).
6932        #[test]
6933        fn serialize_tombstone_includes_deleted_key() {
6934            let m = MarkerInfo {
6935                uid: "abc-123".to_string(),
6936                marker_type: "a-u-G".to_string(),
6937                lat: 0.0,
6938                lon: 0.0,
6939                hae: None,
6940                ts: 1700000000000,
6941                callsign: None,
6942                color: None,
6943                cell_id: None,
6944                deleted: true,
6945            };
6946            let json = serialize_marker_json(&m).expect("serializes");
6947            assert!(
6948                json.contains("\"_deleted\":true"),
6949                "tombstone serialization must include _deleted key, got: {json}"
6950            );
6951        }
6952
6953        /// A live marker's serialization MUST NOT include `_deleted`
6954        /// (saves bytes on the wire AND avoids ambiguity for
6955        /// receivers running an older parser that does a strict
6956        /// `_deleted == true` check).
6957        #[test]
6958        fn serialize_live_marker_omits_deleted_key() {
6959            let m = MarkerInfo {
6960                uid: "abc-123".to_string(),
6961                marker_type: "a-f-G-U-C".to_string(),
6962                lat: 33.71,
6963                lon: -84.41,
6964                hae: Some(312.4),
6965                ts: 1700000000000,
6966                callsign: Some("ALPHA-1".to_string()),
6967                color: Some(-65536),
6968                cell_id: None,
6969                deleted: false,
6970            };
6971            let json = serialize_marker_json(&m).expect("serializes");
6972            assert!(
6973                !json.contains("_deleted"),
6974                "live marker must not emit _deleted key, got: {json}"
6975            );
6976        }
6977
6978        /// `serialize_markers_get_json` (the get_markers / scan-side
6979        /// shape, an array) preserves the tombstone flag when the
6980        /// doc store contains both live and deleted entries. The
6981        /// plugin's `renderAllMarkersFromDocStore` reads this output
6982        /// and must be able to identify which entries are tombstones.
6983        #[test]
6984        fn scan_serializes_tombstones_in_array() {
6985            let live = MarkerInfo {
6986                uid: "live".to_string(),
6987                marker_type: "a-f-G".to_string(),
6988                lat: 1.0,
6989                lon: 2.0,
6990                hae: None,
6991                ts: 1,
6992                callsign: None,
6993                color: None,
6994                cell_id: None,
6995                deleted: false,
6996            };
6997            let dead = MarkerInfo {
6998                deleted: true,
6999                ..live.clone()
7000            };
7001            let mut dead = dead;
7002            dead.uid = "dead".to_string();
7003
7004            let json = serialize_markers_get_json(&[live, dead]);
7005            let arr: serde_json::Value = serde_json::from_str(&json).unwrap();
7006            let arr = arr.as_array().unwrap();
7007            assert_eq!(arr.len(), 2);
7008            // Find by uid; can't rely on order.
7009            let live_obj = arr.iter().find(|v| v["uid"] == "live").unwrap();
7010            let dead_obj = arr.iter().find(|v| v["uid"] == "dead").unwrap();
7011            assert!(
7012                live_obj.get("_deleted").is_none(),
7013                "live entry has no _deleted"
7014            );
7015            assert_eq!(
7016                dead_obj["_deleted"].as_bool(),
7017                Some(true),
7018                "dead entry has _deleted: true"
7019            );
7020        }
7021
7022        /// Round-trip: serialize → parse → serialize. The two
7023        /// serialized strings must be byte-identical. Catches
7024        /// codec drift (e.g., one side adds a field the other
7025        /// drops, or `Option<i64> 0` vs absent disagreements).
7026        #[test]
7027        fn tombstone_round_trip_is_stable() {
7028            let m = MarkerInfo {
7029                uid: "round-trip-uid".to_string(),
7030                marker_type: "a-u-G".to_string(),
7031                lat: 0.0,
7032                lon: 0.0,
7033                hae: None,
7034                ts: 1700000000000,
7035                callsign: None,
7036                color: None,
7037                cell_id: None,
7038                deleted: true,
7039            };
7040            let s1 = serialize_marker_json(&m).unwrap();
7041            let parsed = parse_marker_publish_json("", &s1).expect("parses tombstone");
7042            assert!(parsed.deleted, "deleted flag preserved through round-trip");
7043            assert_eq!(parsed.uid, m.uid);
7044            let s2 = serialize_marker_json(&parsed).unwrap();
7045            assert_eq!(s1, s2, "round-trip must produce byte-identical output");
7046        }
7047    }
7048
7049    /// Surface-tier round-trips for the marker API the plugin
7050    /// actually consumes: the UniFFI `PeatNode::put_marker` /
7051    /// `PeatNode::get_markers` path (typed-record wrapper, doc-store
7052    /// persistence, `MARKERS` collection wiring) and the JNI
7053    /// `publishMarkerJni` / `getMarkersJni` path (inline parser +
7054    /// `serialize_markers_get_json`). These tests are the bidirectional
7055    /// E2E coverage the QA review on PR #845 required — internal
7056    /// codec tests in [`marker_tombstone`] don't catch wrapper-vs-
7057    /// internal drift (renamed UniFFI field, doc-store key mismatch,
7058    /// JNI handle lifecycle regression). Storage-side tests follow
7059    /// the `put_node_get_nodes_preserves_battery_and_heart`
7060    /// pattern in [`node_tests`]: `create_node` against
7061    /// `AutomergeBackend` (not `InMemoryBackend`, which silently
7062    /// papers over the publish-vs-scan storage-API asymmetry — see
7063    /// the InMemoryBackend test gap memory).
7064    #[cfg(feature = "sync")]
7065    mod marker_tests {
7066        use super::*;
7067
7068        fn live_marker(uid: &str) -> MarkerInfo {
7069            MarkerInfo {
7070                uid: uid.to_string(),
7071                marker_type: "a-f-G-U-C".to_string(),
7072                lat: 33.71576,
7073                lon: -84.41152,
7074                hae: Some(312.4),
7075                ts: 1_700_000_000_000,
7076                callsign: Some("ALPHA-1".to_string()),
7077                color: Some(-65536),
7078                cell_id: Some("BRAVO".to_string()),
7079                deleted: false,
7080            }
7081        }
7082
7083        fn tombstone_marker(uid: &str) -> MarkerInfo {
7084            MarkerInfo {
7085                uid: uid.to_string(),
7086                marker_type: TOMBSTONE_PLACEHOLDER_TYPE.to_string(),
7087                lat: 0.0,
7088                lon: 0.0,
7089                hae: None,
7090                ts: 1_700_000_000_000,
7091                callsign: None,
7092                color: None,
7093                cell_id: None,
7094                deleted: true,
7095            }
7096        }
7097
7098        fn make_node(label: &str) -> Arc<PeatNode> {
7099            let tmp = tempfile::tempdir().expect("tempdir");
7100            create_node(NodeConfig {
7101                app_id: format!("marker-rt-{label}"),
7102                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
7103                bind_address: Some("127.0.0.1:0".to_string()),
7104                storage_path: tmp.path().to_str().unwrap().to_string(),
7105                transport: None,
7106            })
7107            .expect("create_node")
7108        }
7109
7110        // ----- UniFFI tier -------------------------------------------------
7111
7112        /// Live marker survives the full UniFFI surface round-trip.
7113        /// Drift point this catches: a future field added to
7114        /// `MarkerInfo` but dropped in `serialize_marker_json` or
7115        /// `parse_marker_publish_json` (the very bug pattern
7116        /// peat#835 / peat#832 sat behind). Every optional field
7117        /// must round-trip; new fields require a parallel assertion
7118        /// below so this matrix stays exhaustive.
7119        #[test]
7120        fn put_marker_get_markers_preserves_live_fields() {
7121            let node = make_node("live");
7122            let original = live_marker("marker-live-001");
7123            node.put_marker(original.clone()).expect("put_marker");
7124
7125            let listed = node.get_markers().expect("get_markers");
7126            let found = listed
7127                .iter()
7128                .find(|m| m.uid == original.uid)
7129                .expect("published marker must appear in get_markers");
7130
7131            assert_eq!(found.marker_type, original.marker_type);
7132            assert_eq!(found.lat, original.lat);
7133            assert_eq!(found.lon, original.lon);
7134            assert_eq!(found.hae, original.hae);
7135            assert_eq!(found.ts, original.ts);
7136            assert_eq!(found.callsign, original.callsign);
7137            assert_eq!(found.color, original.color);
7138            assert_eq!(found.cell_id, original.cell_id);
7139            assert!(!found.deleted, "live marker must not arrive deleted");
7140        }
7141
7142        /// Tombstone survives the UniFFI surface round-trip with the
7143        /// `deleted` flag preserved. Without this assertion a future
7144        /// schema refactor could silently drop `_deleted: true` on
7145        /// store-and-scan — receivers would render the marker as
7146        /// live, the deletion would never propagate, and the only
7147        /// signal would be on-device UAT (the exact bug class the
7148        /// dev-team-owns-validation rule exists to lock in CI).
7149        #[test]
7150        fn put_marker_get_markers_preserves_tombstone() {
7151            let node = make_node("tomb");
7152            let original = tombstone_marker("marker-tomb-001");
7153            node.put_marker(original.clone()).expect("put_marker");
7154
7155            let listed = node.get_markers().expect("get_markers");
7156            let found = listed
7157                .iter()
7158                .find(|m| m.uid == original.uid)
7159                .expect("published tombstone must appear in get_markers");
7160
7161            assert!(found.deleted, "tombstone must round-trip with deleted=true");
7162            assert_eq!(found.uid, original.uid);
7163            assert_eq!(found.ts, original.ts);
7164        }
7165
7166        /// Tombstone overwriting a live marker for the same UID:
7167        /// `put_marker` is upsert, the second write replaces the
7168        /// first. `get_markers` returns the tombstone (deleted=true),
7169        /// not the prior live shape. Locks the CRDT semantics the
7170        /// consumer's deletion flow depends on — without upsert,
7171        /// "delete a marker I just placed" would produce two
7172        /// doc-store entries and ambiguous resolution.
7173        #[test]
7174        fn tombstone_upserts_over_live_marker() {
7175            let node = make_node("upsert");
7176            let uid = "marker-upsert-001";
7177            node.put_marker(live_marker(uid)).expect("put live");
7178            node.put_marker(tombstone_marker(uid)).expect("put tomb");
7179
7180            let listed = node.get_markers().expect("get_markers");
7181            let matching: Vec<_> = listed.iter().filter(|m| m.uid == uid).collect();
7182            assert_eq!(
7183                matching.len(),
7184                1,
7185                "upsert must produce exactly one entry per uid, got {}",
7186                matching.len()
7187            );
7188            assert!(matching[0].deleted, "tombstone must win over prior live");
7189        }
7190
7191        // ----- JNI tier ----------------------------------------------------
7192
7193        /// JNI inline-parser path: `publishMarkerJni` decodes a
7194        /// JString into the same `parse_marker_publish_json` helper
7195        /// the typed UniFFI path skips. Builds a JSON envelope shaped
7196        /// exactly like the consumer's marker serializer produces on
7197        /// the wire and verifies every field lands in the resulting
7198        /// `MarkerInfo`. Locks the duplicated codec — same pattern as
7199        /// `publish_json_inline_parser_extracts_battery_and_heart` in
7200        /// [`node_tests`], same rationale (silent field drop on
7201        /// the publish path).
7202        #[test]
7203        fn publish_json_inline_parser_extracts_live_marker_fields() {
7204            let json = r#"{
7205                "uid": "marker-jni-001",
7206                "type": "a-f-G-U-C",
7207                "lat": 33.71576,
7208                "lon": -84.41152,
7209                "hae": 312.4,
7210                "ts": 1700000000000,
7211                "callsign": "ALPHA-1",
7212                "color": -65536,
7213                "cell_id": "BRAVO"
7214            }"#;
7215
7216            let parsed = parse_marker_publish_json("", json).expect("parse");
7217
7218            assert_eq!(parsed.uid, "marker-jni-001");
7219            assert_eq!(parsed.marker_type, "a-f-G-U-C");
7220            assert_eq!(parsed.lat, 33.71576);
7221            assert_eq!(parsed.lon, -84.41152);
7222            assert_eq!(parsed.hae, Some(312.4));
7223            assert_eq!(parsed.callsign.as_deref(), Some("ALPHA-1"));
7224            assert_eq!(parsed.color, Some(-65536));
7225            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
7226            assert!(!parsed.deleted);
7227        }
7228
7229        /// JNI tombstone inline-parser path: `publishMarkerJni` must
7230        /// accept the stripped tombstone body the consumer's deletion
7231        /// serializer produces (uid + `_deleted: true` + ts, no
7232        /// geo/type/callsign). Catches a regression where the parser
7233        /// tightens up its required-fields validation in a way that
7234        /// breaks the deletion path silently.
7235        #[test]
7236        fn publish_json_inline_parser_accepts_stripped_tombstone() {
7237            let json = r#"{"uid":"marker-jni-tomb-001","_deleted":true,"ts":1700000000000}"#;
7238            let parsed = parse_marker_publish_json("", json).expect("parse stripped tombstone");
7239            assert!(parsed.deleted);
7240            assert_eq!(parsed.uid, "marker-jni-tomb-001");
7241            assert_eq!(parsed.ts, 1_700_000_000_000);
7242            assert_eq!(
7243                parsed.marker_type, TOMBSTONE_PLACEHOLDER_TYPE,
7244                "absent type must resolve to the named placeholder, not a magic literal"
7245            );
7246        }
7247
7248        // ----- JNI + UniFFI: storage round-trip via the get-side serializer
7249        //       (the shape getMarkersJni hands to consumers) -------------
7250
7251        /// `getMarkersJni` serializes `Vec<MarkerInfo>` via
7252        /// `serialize_markers_get_json` — the JSON shape consumers
7253        /// parse. A round-trip test pins that the wire shape
7254        /// `get_markers` emits is one a subsequent
7255        /// `parse_marker_publish_json` accepts, ensuring no
7256        /// asymmetric-codec regression slips through.
7257        #[test]
7258        fn get_markers_jni_serialized_shape_re_parses_cleanly() {
7259            let node = make_node("getjni");
7260            node.put_marker(live_marker("marker-getjni-001"))
7261                .expect("put live");
7262            node.put_marker(tombstone_marker("marker-getjni-002"))
7263                .expect("put tomb");
7264
7265            let listed = node.get_markers().expect("get_markers");
7266            let json = serialize_markers_get_json(&listed);
7267
7268            // Decode every entry through the same inline parser the
7269            // publish path uses. If the get-side shape ever diverges
7270            // from the publish-side shape, this fails before it
7271            // reaches a consumer.
7272            let arr: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
7273            for obj in arr.as_array().expect("array").iter() {
7274                let body = serde_json::to_string(obj).unwrap();
7275                let parsed = parse_marker_publish_json("", &body).expect("get-side body re-parses");
7276                if parsed.uid == "marker-getjni-002" {
7277                    assert!(parsed.deleted, "tombstone preserved in scan output");
7278                } else {
7279                    assert!(!parsed.deleted, "live preserved in scan output");
7280                }
7281            }
7282        }
7283    }
7284}
7285
7286// =============================================================================
7287// JNI Bindings - Direct Android native method support
7288// =============================================================================
7289//
7290// These functions provide a direct JNI interface that bypasses JNA's symbol
7291// lookup issues on Android. When System.loadLibrary() is called, these
7292// functions are registered via JNI's standard naming convention.
7293//
7294// Usage in Kotlin:
7295// ```kotlin
7296// class PeatJni {
7297//     companion object {
7298//         init {
7299//             System.loadLibrary("peat_ffi")
7300//         }
7301//     }
7302//     external fun peatVersion(): String
7303//     external fun testJni(): String
7304// }
7305// ```
7306
7307/// JNI: Get Peat library version
7308///
7309/// Kotlin signature: external fun peatVersion(): String
7310#[no_mangle]
7311pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_peatVersion(
7312    mut env: JNIEnv,
7313    _class: JClass,
7314) -> jstring {
7315    let version = peat_version();
7316    env.new_string(&version)
7317        .expect("Failed to create Java string")
7318        .into_raw()
7319}
7320
7321/// Pinned `GlobalRef` to the Android Context jobject that
7322/// `setAndroidContextJni` last received. The raw pointer we hand to
7323/// `ndk_context::initialize_android_context` is the jobject handle
7324/// inside this GlobalRef; the JVM guarantees the handle remains
7325/// valid (and pointing at the same Java object even if the GC moves
7326/// the underlying heap object) until the GlobalRef is dropped.
7327///
7328/// Storing the GlobalRef in a `Mutex<Option<GlobalRef>>` (rather
7329/// than a `OnceLock`) supports the documented call pattern: the
7330/// surface admits multiple `setAndroidContextJni` invocations, but
7331/// **only before `createNodeJni` starts iroh** (see that fn's
7332/// docstring). The mutex serializes concurrent
7333/// `setAndroidContextJni` callers; it does NOT block readers of
7334/// `ndk_context::android_context()`. Between the
7335/// `release_android_context()` and `initialize_android_context()`
7336/// calls inside `setAndroidContextJni` there is a brief window where
7337/// the global cell is empty — any iroh worker thread that hits
7338/// `android_context()` during that window panics. The pre-iroh-start
7339/// constraint makes the window structurally unreachable in
7340/// practice (no iroh worker exists yet) but a re-init after
7341/// `createNodeJni` is unsafe.
7342#[cfg(target_os = "android")]
7343static ANDROID_CONTEXT_GLOBAL_REF: std::sync::Mutex<Option<jni::objects::GlobalRef>> =
7344    std::sync::Mutex::new(None);
7345
7346/// Set to `true` by `createNodeJni` (and `createNodeWithConfigJni`)
7347/// on first successful node construction; checked by
7348/// `setAndroidContextJni` to reject post-iroh-start invocations.
7349///
7350/// Why this exists: `setAndroidContextJni` must release and
7351/// reinitialize `ndk-context`'s global cell, and there is a brief
7352/// window between the two calls where any iroh worker thread
7353/// reaching `ndk_context::android_context()` panics. The
7354/// `Application.onCreate`-before-`createNodeJni` call pattern keeps
7355/// the window structurally unreachable (no iroh worker exists yet),
7356/// but the Kotlin/Rust doc could be ignored by a consumer that
7357/// re-acquires the Application Context in `onActivityResult` or
7358/// similar. This flag turns that misuse into a logged-and-ignored
7359/// no-op rather than a SIGABRT.
7360///
7361/// One-way: once set, never cleared. Re-init is unsafe by design;
7362/// there is no recovery path. Set via `Release` to publish all
7363/// prior writes (iroh handle install, tokio runtime startup) to any
7364/// `Acquire` reader; checked via `Acquire` to see them. peat#924 QA
7365/// WARNING-2 round 2.
7366#[cfg(target_os = "android")]
7367static IROH_STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
7368
7369/// JNI: Plumb the Android `Context` jobject into `ndk-context`'s
7370/// global cell.
7371///
7372/// Kotlin signature: `external fun setAndroidContextJni(context: Any)`
7373///
7374/// Why this exists: `JNI_OnLoad` initializes `ndk-context` with the
7375/// `JavaVM*` it receives as an argument, but passes `null` for the
7376/// Android `Context` because no `Context` exists yet — `JNI_OnLoad`
7377/// runs before any `Application` has been instantiated by the
7378/// framework. That's enough for the iroh discovery subtree
7379/// (swarm-discovery / mDNS) which only needs the JVM for thread
7380/// attachment. It is NOT enough for code that needs the
7381/// `Context` itself — `hickory-resolver`'s Android `ConnectivityManager`
7382/// probe (transitively reachable via iroh-dns), NDK asset-manager
7383/// access, app-private file path resolution, etc. Those paths panic
7384/// with `android context was not initialized` on first call.
7385///
7386/// Consumers using iroh DNS-based discovery (relay, pkarr,
7387/// non-mDNS peer lookups) MUST call this from
7388/// `Application.onCreate()` passing the application Context BEFORE
7389/// the first `createNodeJni`. Consumers using only mDNS local-link
7390/// discovery (peat-ffi's own surface tests, the QUICKSTART
7391/// scenarios 1–3) can skip it.
7392///
7393/// Multiple calls are allowed, but **only before `createNodeJni`**
7394/// is invoked. Calling this after iroh has started creates a brief
7395/// window between `release_android_context()` and
7396/// `initialize_android_context()` where any concurrent
7397/// `ndk_context::android_context()` reader — iroh-dns
7398/// `hickory-resolver`'s ConnectivityManager probe, the mDNS
7399/// multicast worker, etc. — sees the cell empty and panics with
7400/// "android context was not initialized". The mutex protecting
7401/// `ANDROID_CONTEXT_GLOBAL_REF` serializes concurrent
7402/// `setAndroidContextJni` writers but does NOT block readers
7403/// reaching into `ndk-context`'s own global cell. The
7404/// Application.onCreate-before-createNodeJni call pattern makes
7405/// the window structurally unreachable (no iroh worker exists
7406/// yet); a re-init after iroh starts is unsafe.
7407///
7408/// The JVM pointer remains the same one JNI_OnLoad stored on every
7409/// call; only the Context changes. peat#925 QA WARNING follow-up.
7410#[cfg(target_os = "android")]
7411#[no_mangle]
7412pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_setAndroidContextJni(
7413    env: JNIEnv,
7414    _class: JClass,
7415    context: jni::objects::JObject,
7416) {
7417    // Reject post-iroh-start invocations. The release+reinit pair
7418    // below has a brief window where `ndk_context::android_context()`
7419    // returns the empty cell — once any iroh worker is alive (i.e.
7420    // `createNodeJni` has returned successfully), one of them
7421    // resolving the cell during that window panics. The documented
7422    // call pattern (Application.onCreate before any createNodeJni)
7423    // makes the window unreachable; this `Acquire` load is the
7424    // runtime guardrail for misuse that ignores the doc. peat#924 QA
7425    // WARNING-2 round 2.
7426    use std::sync::atomic::Ordering;
7427    if IROH_STARTED.load(Ordering::Acquire) {
7428        android_log(
7429            "setAndroidContextJni: ignoring — iroh already started; \
7430             call this from Application.onCreate BEFORE createNodeJni. \
7431             See PeatJni.kt KDoc.",
7432        );
7433        return;
7434    }
7435
7436    // JNI delivers `context` as a **local reference** — valid only
7437    // for the duration of this native method call. After we return,
7438    // the JVM is free to recycle the local-ref table slot, and a
7439    // raw pointer to it would alias the wrong (or no) object on the
7440    // next `ndk_context::android_context().context()` lookup.
7441    // Promote to a process-lifetime global reference first, then
7442    // hand `ndk_context` the jobject handle from inside the
7443    // GlobalRef. peat#925 QA WARNING-2.
7444    let global_ref = match env.new_global_ref(&context) {
7445        Ok(gref) => gref,
7446        Err(e) => {
7447            android_log(&format!(
7448                "setAndroidContextJni: env.new_global_ref(context) failed: {}",
7449                e
7450            ));
7451            return;
7452        }
7453    };
7454    let vm_ptr = match env.get_java_vm() {
7455        Ok(vm) => vm.get_java_vm_pointer() as *mut c_void,
7456        Err(_) => {
7457            android_log("setAndroidContextJni: env.get_java_vm() failed");
7458            return;
7459        }
7460    };
7461
7462    // SAFETY: `JNI_OnLoad` cached the JavaVM and called
7463    // `ndk_context::initialize_android_context(vm, null)` exactly
7464    // once at library-load time. `ndk-context 0.1.1` is one-shot —
7465    // calling `initialize_android_context` a second time asserts on
7466    // `previous.is_none()` and SIGABRT's the process (peat#925 QA
7467    // d2d01b23 surface-test surfaced this). The documented re-init
7468    // pattern is `release_android_context()` followed by a fresh
7469    // `initialize_android_context(...)`. We do exactly that here,
7470    // holding the `ANDROID_CONTEXT_GLOBAL_REF` mutex across the pair
7471    // so concurrent `setAndroidContextJni` callers serialize and
7472    // neither sees the cell in a released-but-not-yet-reinitialized
7473    // state. The JavaVM pointer remains the same one JNI_OnLoad
7474    // stored; only the Context changes (from `null` to the
7475    // GlobalRef'd jobject handle on first call; from the previous
7476    // GlobalRef to the new one on subsequent calls).
7477    //
7478    // The jobject handle is pulled from `global_ref.as_raw()` — the
7479    // JVM guarantees this remains valid until the GlobalRef is
7480    // dropped, which we prevent by stashing the GlobalRef in
7481    // `ANDROID_CONTEXT_GLOBAL_REF` below before releasing the lock.
7482    let ctx_ptr = global_ref.as_raw() as *mut c_void;
7483    let mut slot = ANDROID_CONTEXT_GLOBAL_REF.lock().unwrap();
7484    unsafe {
7485        // `release_android_context()` asserts `previous.is_some()`
7486        // — safe because JNI_OnLoad installed the `(vm, null)` entry
7487        // exactly once and this critical section is the only place
7488        // in peat-ffi that releases. If we ever surface a
7489        // `clear_android_context_jni`, it would also need the same
7490        // mutex.
7491        ndk_context::release_android_context();
7492        ndk_context::initialize_android_context(vm_ptr, ctx_ptr);
7493    }
7494    // Replace the cell *after* the ndk_context swap. The drop of
7495    // the previous GlobalRef happens here (out of the Option). The
7496    // new GlobalRef is now the one keeping `ctx_ptr` live.
7497    *slot = Some(global_ref);
7498    drop(slot);
7499
7500    android_log(
7501        "setAndroidContextJni: ndk_context re-initialized with non-null Context (GlobalRef pinned)",
7502    );
7503}
7504
7505/// JNI: Returns whether `ndk-context`'s stored Context is non-null
7506/// — i.e., whether a prior `setAndroidContextJni` call has wired a
7507/// real Application Context into the global cell.
7508///
7509/// Kotlin signature: `external fun verifyAndroidContextJni(): Boolean`
7510///
7511/// Surface-tier test hook (peat#925 QA BLOCKER). Lets an
7512/// instrumented Android test assert end-to-end that
7513/// Kotlin → JNI → Rust → `ndk_context` actually wired the Context
7514/// through, without having to drive a downstream consumer (e.g.,
7515/// hickory-resolver's Android system-DNS probe) just to verify
7516/// the plumbing. Production code should not call this — the
7517/// information is internal to the wiring contract.
7518#[cfg(target_os = "android")]
7519#[no_mangle]
7520pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_verifyAndroidContextJni(
7521    _env: JNIEnv,
7522    _class: JClass,
7523) -> jni::sys::jboolean {
7524    let stored = ndk_context::android_context().context();
7525    if stored.is_null() {
7526        jni::sys::JNI_FALSE
7527    } else {
7528        jni::sys::JNI_TRUE
7529    }
7530}
7531
7532/// JNI: Test that JNI bindings work
7533///
7534/// Kotlin signature: external fun testJni(): String
7535#[no_mangle]
7536pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_testJni(
7537    mut env: JNIEnv,
7538    _class: JClass,
7539) -> jstring {
7540    let msg = "JNI bindings working! Peat FFI loaded successfully.";
7541    env.new_string(msg)
7542        .expect("Failed to create Java string")
7543        .into_raw()
7544}
7545
7546/// JNI: Create a Peat node (simplified for testing)
7547///
7548/// Kotlin signature: external fun createNodeJni(appId: String, sharedKey:
7549/// String, storagePath: String): Long
7550#[cfg(feature = "sync")]
7551#[no_mangle]
7552pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_createNodeJni(
7553    mut env: JNIEnv,
7554    _class: JClass,
7555    app_id: JString,
7556    shared_key: JString,
7557    storage_path: JString,
7558) -> i64 {
7559    let app_id: String = match env.get_string(&app_id) {
7560        Ok(s) => s.into(),
7561        Err(_) => return 0,
7562    };
7563    let shared_key: String = match env.get_string(&shared_key) {
7564        Ok(s) => s.into(),
7565        Err(_) => return 0,
7566    };
7567    let storage_path: String = match env.get_string(&storage_path) {
7568        Ok(s) => s.into(),
7569        Err(_) => return 0,
7570    };
7571
7572    #[cfg(target_os = "android")]
7573    android_log(&format!(
7574        "createNodeJni: app_id={}, storage_path={}",
7575        app_id, storage_path
7576    ));
7577
7578    let config = NodeConfig {
7579        app_id,
7580        shared_key,
7581        bind_address: None,
7582        storage_path,
7583        transport: None,
7584    };
7585
7586    match create_node(config) {
7587        Ok(node) => {
7588            #[cfg(target_os = "android")]
7589            android_log("createNodeJni: Node created successfully");
7590            // Publish "iroh has started" to any future
7591            // `setAndroidContextJni` reader BEFORE handing the
7592            // handle back to Kotlin. `Release` here pairs with
7593            // `Acquire` in setAndroidContextJni — guarantees all
7594            // writes leading up to this point (iroh handle install,
7595            // tokio runtime startup, iroh worker spawn) are visible
7596            // to a setAndroidContextJni call that observes the flag
7597            // set. One-way: never cleared, even on `freeNodeJni` —
7598            // re-issuing setAndroidContextJni after a node lifecycle
7599            // is still unsafe because iroh tokio workers may
7600            // outlive the node handle.
7601            #[cfg(target_os = "android")]
7602            IROH_STARTED.store(true, std::sync::atomic::Ordering::Release);
7603            // Return the Arc pointer as a handle
7604            // Store an OWNING reference in the global (survives APK
7605            // replacement) BEFORE consuming `node` into the JNI handle, so the
7606            // global owns its own ref rather than aliasing the handle. Released
7607            // by clearGlobalNodeHandleJni, independent of this handle's
7608            // freeNodeJni. See set_global_node_handle.
7609            set_global_node_handle(&node);
7610            let handle = Arc::into_raw(node) as i64;
7611            #[cfg(target_os = "android")]
7612            android_log(&format!("createNodeJni: Stored global handle: {}", handle));
7613            handle
7614        }
7615        Err(e) => {
7616            #[cfg(target_os = "android")]
7617            android_log(&format!("createNodeJni: Error creating node: {:?}", e));
7618            0
7619        }
7620    }
7621}
7622
7623/// JNI: Create a PeatNode with transport configuration (ADR-039, #558)
7624///
7625/// This extended version supports BLE transport configuration for unified
7626/// multi-transport operation. When enable_ble is true, the node will attempt
7627/// to initialize BLE transport alongside the default Iroh transport.
7628///
7629/// Note: On Android, BLE transport requires the Android BLE adapter to be
7630/// initialized via JNI callbacks. Full BLE support is pending Android adapter
7631/// integration in peat-btle.
7632///
7633/// Kotlin signature:
7634/// ```kotlin
7635/// external fun createNodeWithConfigJni(
7636///     appId: String,
7637///     sharedKey: String,
7638///     storagePath: String,
7639///     enableBle: Boolean,
7640///     blePowerProfile: String?  // "aggressive", "balanced", or "low_power"
7641/// ): Long
7642/// ```
7643#[cfg(feature = "sync")]
7644#[no_mangle]
7645pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_createNodeWithConfigJni(
7646    mut env: JNIEnv,
7647    _class: JClass,
7648    app_id: JString,
7649    shared_key: JString,
7650    storage_path: JString,
7651    enable_ble: jboolean,
7652    ble_power_profile: JString,
7653) -> i64 {
7654    let app_id: String = match env.get_string(&app_id) {
7655        Ok(s) => s.into(),
7656        Err(_) => return 0,
7657    };
7658    let shared_key: String = match env.get_string(&shared_key) {
7659        Ok(s) => s.into(),
7660        Err(_) => return 0,
7661    };
7662    let storage_path: String = match env.get_string(&storage_path) {
7663        Ok(s) => s.into(),
7664        Err(_) => return 0,
7665    };
7666
7667    // Parse BLE power profile (null/empty string means use default)
7668    let power_profile: Option<String> = env.get_string(&ble_power_profile).ok().and_then(|s| {
7669        let s: String = s.into();
7670        if s.is_empty() {
7671            None
7672        } else {
7673            Some(s)
7674        }
7675    });
7676
7677    #[cfg(target_os = "android")]
7678    android_log(&format!(
7679        "createNodeWithConfigJni: app_id={}, storage_path={}, enable_ble={}, power_profile={:?}",
7680        app_id,
7681        storage_path,
7682        enable_ble != 0,
7683        power_profile
7684    ));
7685
7686    // Build transport configuration
7687    let transport_config = if enable_ble != 0 {
7688        Some(TransportConfigFFI {
7689            enable_ble: true,
7690            ble_mesh_id: None, // Use app_id as mesh ID
7691            ble_power_profile: power_profile,
7692            transport_preference: None,
7693            collection_routes_json: None,
7694            // This legacy/convenience entry doesn't expose the relay toggle;
7695            // keep the local-only posture. The Dart `create_node` path sets
7696            // this from the About-tab toggle.
7697            enable_n0_relay: false,
7698        })
7699    } else {
7700        None
7701    };
7702
7703    let config = NodeConfig {
7704        app_id,
7705        shared_key,
7706        bind_address: None,
7707        storage_path,
7708        transport: transport_config,
7709    };
7710
7711    match create_node(config) {
7712        Ok(node) => {
7713            #[cfg(target_os = "android")]
7714            android_log("createNodeWithConfigJni: Node created successfully");
7715            // Publish iroh-started — same Release/Acquire pairing
7716            // with setAndroidContextJni as in createNodeJni above.
7717            // peat#924 QA WARNING-2.
7718            #[cfg(target_os = "android")]
7719            IROH_STARTED.store(true, std::sync::atomic::Ordering::Release);
7720            // Owning global ref before consuming `node` (see set_global_node_handle).
7721            set_global_node_handle(&node);
7722            let handle = Arc::into_raw(node) as i64;
7723            #[cfg(target_os = "android")]
7724            android_log(&format!(
7725                "createNodeWithConfigJni: Stored global handle: {}",
7726                handle
7727            ));
7728            handle
7729        }
7730        Err(e) => {
7731            #[cfg(target_os = "android")]
7732            android_log(&format!(
7733                "createNodeWithConfigJni: Error creating node: {:?}",
7734                e
7735            ));
7736            0
7737        }
7738    }
7739}
7740
7741/// JNI: Get the global node handle (survives APK replacement)
7742///
7743/// Kotlin signature: external fun getGlobalNodeHandleJni(): Long
7744#[cfg(feature = "sync")]
7745#[no_mangle]
7746pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getGlobalNodeHandleJni(
7747    _env: JNIEnv,
7748    _class: JClass,
7749) -> i64 {
7750    match GLOBAL_NODE_HANDLE.lock() {
7751        Ok(handle) => {
7752            let h = *handle;
7753            #[cfg(target_os = "android")]
7754            android_log(&format!("getGlobalNodeHandleJni: Returning handle: {}", h));
7755            h
7756        }
7757        Err(_) => 0,
7758    }
7759}
7760
7761/// JNI: Release the owning reference stored in [`GLOBAL_NODE_HANDLE`].
7762///
7763/// Counterpart to the `set_global_node_handle` write performed by every
7764/// node-create path. The bridge that consumes `getGlobalNodeHandleJni` (e.g.
7765/// the BLE pipe) calls this on teardown so the node can actually be freed
7766/// once its originating handle is also released. Safe to call repeatedly and
7767/// when no handle is stored (no-op on `0`).
7768///
7769/// Kotlin signature: `external fun clearGlobalNodeHandleJni()`
7770#[cfg(feature = "sync")]
7771#[no_mangle]
7772pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_clearGlobalNodeHandleJni(
7773    _env: JNIEnv,
7774    _class: JClass,
7775) {
7776    clear_owning_node_slot(&GLOBAL_NODE_HANDLE);
7777}
7778
7779/// JNI: Get node ID from a PeatNode handle
7780///
7781/// Kotlin signature: external fun nodeIdJni(handle: Long): String
7782#[cfg(feature = "sync")]
7783#[no_mangle]
7784pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_nodeIdJni(
7785    mut env: JNIEnv,
7786    _class: JClass,
7787    handle: i64,
7788) -> jstring {
7789    if handle == 0 {
7790        return env
7791            .new_string("")
7792            .expect("Failed to create Java string")
7793            .into_raw();
7794    }
7795
7796    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7797    let node_id = node.node_id();
7798
7799    // Don't drop the Arc - we're just borrowing
7800    std::mem::forget(node);
7801
7802    env.new_string(&node_id)
7803        .expect("Failed to create Java string")
7804        .into_raw()
7805}
7806
7807/// JNI: Get peer count from a PeatNode handle
7808///
7809/// Kotlin signature: external fun peerCountJni(handle: Long): Int
7810#[cfg(feature = "sync")]
7811#[no_mangle]
7812pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_peerCountJni(
7813    _env: JNIEnv,
7814    _class: JClass,
7815    handle: i64,
7816) -> i32 {
7817    if handle == 0 {
7818        return 0;
7819    }
7820
7821    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7822    let count = node.peer_count() as i32;
7823
7824    // Don't drop the Arc - we're just borrowing
7825    std::mem::forget(node);
7826
7827    count
7828}
7829
7830/// JNI: Request full document sync with all connected peers
7831///
7832/// Kotlin signature: external fun requestSyncJni(handle: Long): Boolean
7833#[cfg(feature = "sync")]
7834#[no_mangle]
7835pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_requestSyncJni(
7836    _env: JNIEnv,
7837    _class: JClass,
7838    handle: i64,
7839) -> jboolean {
7840    if handle == 0 {
7841        return 0;
7842    }
7843    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7844    let result = node.request_sync().is_ok();
7845    std::mem::forget(node);
7846    result as jboolean
7847}
7848
7849/// JNI: Get this node's iroh-endpoint first IP socket address as an
7850/// `"ip:port"` string, or null if no socket is bound. The result is
7851/// what `connectPeerJni` expects as its `address` argument when one
7852/// in-process instance dials another on loopback (no discovery layer
7853/// to populate it). peat-mesh#138 M4.
7854///
7855/// Kotlin signature: external fun endpointSocketAddrJni(handle: Long): String?
7856#[cfg(feature = "sync")]
7857#[no_mangle]
7858pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_endpointSocketAddrJni(
7859    env: JNIEnv,
7860    _class: JClass,
7861    handle: i64,
7862) -> jstring {
7863    if handle == 0 {
7864        return std::ptr::null_mut();
7865    }
7866    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7867    let addr = node.endpoint_socket_addr();
7868    std::mem::forget(node);
7869    match addr {
7870        Some(s) => env
7871            .new_string(s)
7872            .map(|js| js.into_raw())
7873            .unwrap_or(std::ptr::null_mut()),
7874        None => std::ptr::null_mut(),
7875    }
7876}
7877
7878/// Serialize a `peat_mesh::Document` back into the JSON-object shape
7879/// the consumer originally posted via `publishDocumentJni`. The
7880/// publish path hoists an `"id"` field to `Document::id`; this
7881/// helper reinserts it so the round-trip preserves the consumer's
7882/// input shape. Extracted from `getDocumentJni`'s body so the
7883/// serialization can be exercised by an in-crate test independent
7884/// of a JVM (peat#879 QA round 2 — surface-tier coverage for the
7885/// JSON output path).
7886#[cfg(feature = "sync")]
7887fn serialize_document_for_get_jni(doc: &peat_mesh::sync::Document) -> String {
7888    let mut obj = serde_json::Map::new();
7889    for (k, v) in &doc.fields {
7890        obj.insert(k.clone(), v.clone());
7891    }
7892    if let Some(id) = &doc.id {
7893        obj.insert("id".to_string(), serde_json::Value::String(id.clone()));
7894    }
7895    serde_json::Value::Object(obj).to_string()
7896}
7897
7898/// JNI: Read a document back from the local store as JSON, or null
7899/// if the document doesn't exist locally. Complements
7900/// `publishDocumentJni` — needed by instrumented tests that verify
7901/// sync convergence by reading on the receiver side. peat-mesh#138 M4.
7902///
7903/// Kotlin signature: external fun getDocumentJni(handle: Long, collection:
7904/// String, docId: String): String?
7905#[cfg(feature = "sync")]
7906#[no_mangle]
7907pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getDocumentJni(
7908    mut env: JNIEnv,
7909    _class: JClass,
7910    handle: i64,
7911    collection: JString,
7912    doc_id: JString,
7913) -> jstring {
7914    if handle == 0 {
7915        return std::ptr::null_mut();
7916    }
7917    // peat#885 fault-injection short-circuit, consumed before any
7918    // store interaction. `swap(false, ...)` is one-shot — the next
7919    // call returns to the normal read path. Test-only by API
7920    // contract; production callers never arm the flag.
7921    if FORCE_STORE_ERROR_FOR_TESTING.swap(false, std::sync::atomic::Ordering::SeqCst) {
7922        let _ = env.throw_new(
7923            "java/lang/RuntimeException",
7924            "getDocumentJni: forced store error (test fault injection)",
7925        );
7926        return std::ptr::null_mut();
7927    }
7928    let collection_str: String = match env.get_string(&collection) {
7929        Ok(s) => s.into(),
7930        Err(_) => return std::ptr::null_mut(),
7931    };
7932    let doc_id_str: String = match env.get_string(&doc_id) {
7933        Ok(s) => s.into(),
7934        Err(_) => return std::ptr::null_mut(),
7935    };
7936    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
7937    let mesh_node = Arc::clone(&node_owner.node);
7938    let runtime = Arc::clone(&node_owner.runtime);
7939    std::mem::forget(node_owner);
7940
7941    // Read through the same `peat_mesh::Node` document layer that
7942    // `publishDocumentJni` writes to. The older raw-bytes
7943    // `PeatNode::get_document` reads from a different storage path
7944    // (`storage_backend.collection(...)`) and won't see docs that
7945    // arrived via the document layer's publish or that sync replicas
7946    // applied as Automerge ops. peat-mesh#138 M4 / peat#879 QA.
7947    let result = runtime.block_on(mesh_node.get(&collection_str, &doc_id_str));
7948    match result {
7949        Ok(Some(doc)) => {
7950            let json = serialize_document_for_get_jni(&doc);
7951            env.new_string(json)
7952                .map(|js| js.into_raw())
7953                .unwrap_or(std::ptr::null_mut())
7954        }
7955        Ok(None) => std::ptr::null_mut(),
7956        Err(e) => {
7957            // Distinguish "store read failed" from "not present"
7958            // (peat#879 QA WARNING) — silent null on Err would mask
7959            // hard storage errors as ongoing sync-not-converged, and
7960            // the consumer would spin until timeout. Throw across the
7961            // JNI boundary so the caller sees a fail-fast exception
7962            // with the underlying cause.
7963            let msg = format!("getDocumentJni: store read failed: {e}");
7964            let _ = env.throw_new("java/lang/RuntimeException", &msg);
7965            std::ptr::null_mut()
7966        }
7967    }
7968}
7969
7970/// JNI: Test-only fault injection. Arms a one-shot flag so the next
7971/// `getDocumentJni` call short-circuits to the Err branch (throws
7972/// RuntimeException) without touching the underlying store. Self-
7973/// clears on consumption.
7974///
7975/// Exists so consumers can write a deterministic regression test for
7976/// the `getDocumentJni` `Err(_) → env.throw_new` contract without
7977/// depending on Automerge LRU eviction behavior. See peat#885 /
7978/// peat-mesh#138 M4b carryover.
7979///
7980/// Returns 1 on success, 0 if the handle is invalid.
7981///
7982/// Kotlin signature: external fun forceStoreErrorForTestingJni(handle: Long):
7983/// Boolean
7984#[cfg(feature = "sync")]
7985#[no_mangle]
7986pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_forceStoreErrorForTestingJni(
7987    _env: JNIEnv,
7988    _class: JClass,
7989    handle: i64,
7990) -> jboolean {
7991    if handle == 0 {
7992        return 0;
7993    }
7994    FORCE_STORE_ERROR_FOR_TESTING.store(true, std::sync::atomic::Ordering::SeqCst);
7995    1
7996}
7997
7998/// JNI: Get connected peer IDs as a JSON array
7999///
8000/// Kotlin signature: external fun connectedPeersJni(handle: Long): String
8001/// Returns JSON array of hex-encoded peer IDs, or "[]" on error
8002#[cfg(feature = "sync")]
8003#[no_mangle]
8004pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_connectedPeersJni(
8005    mut env: JNIEnv,
8006    _class: JClass,
8007    handle: i64,
8008) -> jstring {
8009    if handle == 0 {
8010        return env
8011            .new_string("[]")
8012            .expect("Failed to create Java string")
8013            .into_raw();
8014    }
8015
8016    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8017    let peers = node.connected_peers();
8018    let result = serde_json::to_string(&peers).unwrap_or_else(|_| "[]".to_string());
8019
8020    // Don't drop the Arc - we're just borrowing
8021    std::mem::forget(node);
8022
8023    env.new_string(&result)
8024        .expect("Failed to create Java string")
8025        .into_raw()
8026}
8027
8028/// JNI: Start sync on a PeatNode
8029///
8030/// Kotlin signature: external fun startSyncJni(handle: Long): Boolean
8031#[cfg(feature = "sync")]
8032#[no_mangle]
8033pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_startSyncJni(
8034    _env: JNIEnv,
8035    _class: JClass,
8036    handle: i64,
8037) -> bool {
8038    // CRITICAL DEBUG: Log unconditionally to verify this function is called
8039    eprintln!("startSyncJni: CALLED with handle={}", handle);
8040    #[cfg(target_os = "android")]
8041    android_log(&format!("startSyncJni: ENTERED with handle={}", handle));
8042
8043    if handle == 0 {
8044        #[cfg(target_os = "android")]
8045        android_log("startSyncJni: handle is 0, returning false");
8046        return false;
8047    }
8048
8049    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8050
8051    #[cfg(target_os = "android")]
8052    android_log("startSyncJni: calling node.start_sync()");
8053
8054    let result = match node.start_sync() {
8055        Ok(()) => {
8056            #[cfg(target_os = "android")]
8057            android_log("startSyncJni: start_sync succeeded");
8058            true
8059        }
8060        Err(e) => {
8061            #[cfg(target_os = "android")]
8062            android_log(&format!("startSyncJni: start_sync failed: {}", e));
8063            false
8064        }
8065    };
8066
8067    // Don't drop the Arc - we're just borrowing
8068    std::mem::forget(node);
8069
8070    result
8071}
8072
8073/// JNI: Free a PeatNode handle
8074///
8075/// Kotlin signature: external fun freeNodeJni(handle: Long)
8076#[cfg(feature = "sync")]
8077#[no_mangle]
8078pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_freeNodeJni(
8079    _env: JNIEnv,
8080    _class: JClass,
8081    handle: i64,
8082) {
8083    if handle != 0 {
8084        #[cfg(target_os = "android")]
8085        android_log(&format!("freeNodeJni: Freeing node handle {}", handle));
8086
8087        // Reconstruct the Arc to drop it
8088        let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8089
8090        // Signal the cleanup task to stop
8091        node.cleanup_running.store(false, Ordering::SeqCst);
8092
8093        #[cfg(target_os = "android")]
8094        android_log("freeNodeJni: Signaled cleanup task to stop");
8095
8096        // Give the background task a moment to exit
8097        std::thread::sleep(std::time::Duration::from_millis(100));
8098
8099        // Clear Android BLE transport global to prevent dangling refs
8100        #[cfg(all(feature = "bluetooth", target_os = "android"))]
8101        {
8102            *ANDROID_BLE_TRANSPORT.lock().unwrap() = None;
8103            android_log("freeNodeJni: Cleared ANDROID_BLE_TRANSPORT");
8104        }
8105
8106        // Drop the node - this should release the database
8107        drop(node);
8108
8109        #[cfg(target_os = "android")]
8110        android_log("freeNodeJni: Node dropped");
8111    }
8112}
8113
8114// =============================================================================
8115// BLE Transport JNI Methods (Android)
8116// =============================================================================
8117
8118/// JNI: Signal BLE transport started/stopped
8119///
8120/// Called by Kotlin when the Android BLE stack is ready or shutting down.
8121/// This makes `is_available()` return true/false for PACE routing.
8122///
8123/// Kotlin signature: external fun bleSetStartedJni(handle: Long, started:
8124/// Boolean)
8125#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8126#[no_mangle]
8127pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleSetStartedJni(
8128    _env: JNIEnv,
8129    _class: JClass,
8130    handle: i64,
8131    started: jboolean,
8132) {
8133    if handle == 0 {
8134        android_log("bleSetStartedJni: Invalid handle (0)");
8135        return;
8136    }
8137
8138    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8139
8140    use peat_protocol::transport::MeshTransport;
8141
8142    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8143    if let Some(ref ble_transport) = *guard {
8144        if started != 0 {
8145            match node.runtime.block_on(ble_transport.start()) {
8146                Ok(()) => android_log("bleSetStartedJni: BLE transport started"),
8147                Err(e) => android_log(&format!("bleSetStartedJni: start failed: {}", e)),
8148            }
8149        } else {
8150            match node.runtime.block_on(ble_transport.stop()) {
8151                Ok(()) => android_log("bleSetStartedJni: BLE transport stopped"),
8152                Err(e) => android_log(&format!("bleSetStartedJni: stop failed: {}", e)),
8153            }
8154        }
8155    } else {
8156        android_log("bleSetStartedJni: No BLE transport registered");
8157    }
8158    drop(guard);
8159
8160    // Don't drop the Arc - we're just borrowing
8161    std::mem::forget(node);
8162}
8163
8164/// JNI: Add a reachable BLE peer
8165///
8166/// Called by Kotlin when a BLE peer is discovered/connected.
8167/// This makes `can_reach(peer)` return true for PACE routing.
8168///
8169/// Kotlin signature: external fun bleAddPeerJni(handle: Long, peerId: String)
8170#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8171#[no_mangle]
8172pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleAddPeerJni(
8173    mut env: JNIEnv,
8174    _class: JClass,
8175    handle: i64,
8176    peer_id: JString,
8177) {
8178    if handle == 0 {
8179        android_log("bleAddPeerJni: Invalid handle (0)");
8180        return;
8181    }
8182
8183    let peer_id_str: String = match env.get_string(&peer_id) {
8184        Ok(s) => s.into(),
8185        Err(_) => {
8186            android_log("bleAddPeerJni: Failed to get peer_id string");
8187            return;
8188        }
8189    };
8190
8191    android_log(&format!("bleAddPeerJni: Adding peer {}", peer_id_str));
8192
8193    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8194    if let Some(ref ble_transport) = *guard {
8195        use peat_protocol::transport::NodeId;
8196        ble_transport.add_reachable_peer(NodeId::new(peer_id_str));
8197    } else {
8198        android_log("bleAddPeerJni: No BLE transport registered");
8199    }
8200}
8201
8202/// JNI: Remove a reachable BLE peer
8203///
8204/// Called by Kotlin when a BLE peer is disconnected/lost.
8205/// This makes `can_reach(peer)` return false for PACE routing.
8206///
8207/// Kotlin signature: external fun bleRemovePeerJni(handle: Long, peerId:
8208/// String)
8209#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8210#[no_mangle]
8211pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleRemovePeerJni(
8212    mut env: JNIEnv,
8213    _class: JClass,
8214    handle: i64,
8215    peer_id: JString,
8216) {
8217    if handle == 0 {
8218        android_log("bleRemovePeerJni: Invalid handle (0)");
8219        return;
8220    }
8221
8222    let peer_id_str: String = match env.get_string(&peer_id) {
8223        Ok(s) => s.into(),
8224        Err(_) => {
8225            android_log("bleRemovePeerJni: Failed to get peer_id string");
8226            return;
8227        }
8228    };
8229
8230    android_log(&format!("bleRemovePeerJni: Removing peer {}", peer_id_str));
8231
8232    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8233    if let Some(ref ble_transport) = *guard {
8234        use peat_protocol::transport::NodeId;
8235        ble_transport.remove_reachable_peer(&NodeId::new(peer_id_str));
8236    } else {
8237        android_log("bleRemovePeerJni: No BLE transport registered");
8238    }
8239}
8240
8241/// JNI: Query whether BLE transport is available (started)
8242///
8243/// Called by Kotlin to check if BLE transport is active for UI display.
8244/// Returns true if BLE transport has been started via bleSetStartedJni.
8245///
8246/// Kotlin signature: external fun bleIsAvailableJni(handle: Long): Boolean
8247#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8248#[no_mangle]
8249pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleIsAvailableJni(
8250    _env: JNIEnv,
8251    _class: JClass,
8252    handle: i64,
8253) -> jboolean {
8254    if handle == 0 {
8255        android_log("bleIsAvailableJni: Invalid handle (0)");
8256        return 0;
8257    }
8258
8259    use peat_protocol::transport::Transport;
8260
8261    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8262    let result = match guard.as_ref() {
8263        Some(t) => {
8264            if t.is_available() {
8265                1
8266            } else {
8267                0
8268            }
8269        }
8270        None => 0,
8271    };
8272
8273    android_log(&format!("bleIsAvailableJni: {}", result != 0));
8274    result
8275}
8276
8277/// JNI: Get the number of reachable BLE peers
8278///
8279/// Called by Kotlin to get BLE peer count for unified UI display.
8280/// Returns the number of peers added via bleAddPeerJni.
8281///
8282/// Kotlin signature: external fun blePeerCountJni(handle: Long): Int
8283#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8284#[no_mangle]
8285pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blePeerCountJni(
8286    _env: JNIEnv,
8287    _class: JClass,
8288    handle: i64,
8289) -> jint {
8290    if handle == 0 {
8291        android_log("blePeerCountJni: Invalid handle (0)");
8292        return 0;
8293    }
8294
8295    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8296    let count = match guard.as_ref() {
8297        Some(t) => t.reachable_peer_count() as jint,
8298        None => 0,
8299    };
8300
8301    android_log(&format!("blePeerCountJni: {}", count));
8302    count
8303}
8304
8305/// JNI: Get all cells as JSON array string
8306///
8307/// Kotlin signature: external fun getCellsJni(handle: Long): String
8308/// Returns JSON array of cell objects, or "[]" on error
8309#[cfg(feature = "sync")]
8310#[no_mangle]
8311pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getCellsJni(
8312    mut env: JNIEnv,
8313    _class: JClass,
8314    handle: i64,
8315) -> jstring {
8316    if handle == 0 {
8317        return env
8318            .new_string("[]")
8319            .expect("Failed to create Java string")
8320            .into_raw();
8321    }
8322
8323    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8324    let result = match node.get_cells() {
8325        Ok(cells) => {
8326            let json_array: Vec<serde_json::Value> = cells
8327                .iter()
8328                .map(|c| {
8329                    serde_json::json!({
8330                        "id": c.id,
8331                        "name": c.name,
8332                        "status": c.status.as_str(),
8333                        "node_count": c.node_count,
8334                        "center_lat": c.center_lat,
8335                        "center_lon": c.center_lon,
8336                        "capabilities": c.capabilities,
8337                        "formation_id": c.formation_id,
8338                        "leader_id": c.leader_id,
8339                        "last_update": c.last_update,
8340                        "scenario_command": c.scenario_command,
8341                    })
8342                })
8343                .collect();
8344            serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
8345        }
8346        Err(_) => "[]".to_string(),
8347    };
8348
8349    // Don't drop the Arc - we're just borrowing
8350    std::mem::forget(node);
8351
8352    env.new_string(&result)
8353        .expect("Failed to create Java string")
8354        .into_raw()
8355}
8356
8357/// JNI: Get all tracks as JSON array string
8358///
8359/// Kotlin signature: external fun getTracksJni(handle: Long): String
8360/// Returns JSON array of track objects, or "[]" on error
8361#[cfg(feature = "sync")]
8362#[no_mangle]
8363pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getTracksJni(
8364    mut env: JNIEnv,
8365    _class: JClass,
8366    handle: i64,
8367) -> jstring {
8368    if handle == 0 {
8369        return env
8370            .new_string("[]")
8371            .expect("Failed to create Java string")
8372            .into_raw();
8373    }
8374
8375    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8376    let result = match node.get_tracks() {
8377        Ok(tracks) => {
8378            let json_array: Vec<serde_json::Value> = tracks
8379                .iter()
8380                .map(|t| {
8381                    serde_json::json!({
8382                        "id": t.id,
8383                        "source_node": t.source_node,
8384                        "cell_id": t.cell_id,
8385                        "formation_id": t.formation_id,
8386                        "lat": t.lat,
8387                        "lon": t.lon,
8388                        "hae": t.hae,
8389                        "cep": t.cep,
8390                        "heading": t.heading,
8391                        "speed": t.speed,
8392                        "classification": t.classification,
8393                        "confidence": t.confidence,
8394                        "category": t.category.as_str(),
8395                        "created_at": t.created_at,
8396                        "last_update": t.last_update,
8397                        "attributes": t.attributes,
8398                    })
8399                })
8400                .collect();
8401            serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
8402        }
8403        Err(_) => "[]".to_string(),
8404    };
8405
8406    // Don't drop the Arc - we're just borrowing
8407    std::mem::forget(node);
8408
8409    env.new_string(&result)
8410        .expect("Failed to create Java string")
8411        .into_raw()
8412}
8413
8414/// JNI: Get all nodes as JSON array string
8415///
8416/// Kotlin signature: external fun getNodesJni(handle: Long): String
8417/// Returns JSON array of node objects, or "[]" on error
8418#[cfg(feature = "sync")]
8419#[no_mangle]
8420pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getNodesJni(
8421    mut env: JNIEnv,
8422    _class: JClass,
8423    handle: i64,
8424) -> jstring {
8425    if handle == 0 {
8426        return env
8427            .new_string("[]")
8428            .expect("Failed to create Java string")
8429            .into_raw();
8430    }
8431
8432    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8433    let result = match node.get_nodes() {
8434        Ok(nodes) => serialize_nodes_get_json(&nodes),
8435        Err(_) => "[]".to_string(),
8436    };
8437
8438    // Don't drop the Arc - we're just borrowing
8439    std::mem::forget(node);
8440
8441    env.new_string(&result)
8442        .expect("Failed to create Java string")
8443        .into_raw()
8444}
8445
8446/// JNI: Get all commands as JSON array string
8447///
8448/// Kotlin signature: external fun getCommandsJni(handle: Long): String
8449/// Returns JSON array of command objects, or "[]" on error
8450#[cfg(feature = "sync")]
8451#[no_mangle]
8452pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getCommandsJni(
8453    mut env: JNIEnv,
8454    _class: JClass,
8455    handle: i64,
8456) -> jstring {
8457    if handle == 0 {
8458        return env
8459            .new_string("[]")
8460            .expect("Failed to create Java string")
8461            .into_raw();
8462    }
8463
8464    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8465    let result = match node.get_commands() {
8466        Ok(commands) => {
8467            let json_array: Vec<serde_json::Value> = commands
8468                .iter()
8469                .map(|c| {
8470                    serde_json::json!({
8471                        "id": c.id,
8472                        "command_type": c.command_type,
8473                        "target_id": c.target_id,
8474                        "parameters": c.parameters,
8475                        "priority": c.priority,
8476                        "status": c.status.as_str(),
8477                        "originator": c.originator,
8478                        "created_at": c.created_at,
8479                        "last_update": c.last_update,
8480                    })
8481                })
8482                .collect();
8483            serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
8484        }
8485        Err(_) => "[]".to_string(),
8486    };
8487
8488    // Don't drop the Arc - we're just borrowing
8489    std::mem::forget(node);
8490
8491    env.new_string(&result)
8492        .expect("Failed to create Java string")
8493        .into_raw()
8494}
8495
8496/// JNI: Publish a node (self-position/PLI) to the Peat network
8497///
8498/// Kotlin signature: external fun publishNodeJni(handle: Long, nodeJson:
8499/// String): Boolean Stores the node in the "nodes" collection for sync to
8500/// peers.
8501///
8502/// Expected JSON format:
8503/// ```json
8504/// {
8505///   "id": "consumer-device-uid",
8506///   "name": "CALLSIGN",
8507///   "node_type": "SOLDIER",
8508///   "lat": 33.7490,
8509///   "lon": -84.3880,
8510///   "hae": 320.0,
8511///   "heading": 45.0,
8512///   "speed": 1.5,
8513///   "status": "ACTIVE",
8514///   "capabilities": ["PLI"],
8515///   "cell_id": null,
8516///   "readiness": 1.0
8517/// }
8518/// ```
8519#[cfg(feature = "sync")]
8520#[no_mangle]
8521pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishNodeJni(
8522    mut env: JNIEnv,
8523    _class: JClass,
8524    handle: i64,
8525    node_json: JString,
8526) -> jboolean {
8527    if handle == 0 {
8528        #[cfg(target_os = "android")]
8529        android_log("publishNodeJni: Invalid handle (0)");
8530        return 0; // JNI_FALSE
8531    }
8532
8533    // Get node JSON string from Java
8534    let json_str: String = match env.get_string(&node_json) {
8535        Ok(s) => s.into(),
8536        Err(e) => {
8537            #[cfg(target_os = "android")]
8538            android_log(&format!(
8539                "publishNodeJni: Failed to get JSON string: {:?}",
8540                e
8541            ));
8542            return 0; // JNI_FALSE
8543        }
8544    };
8545
8546    #[cfg(target_os = "android")]
8547    android_log(&format!("publishNodeJni: Received JSON: {}", json_str));
8548
8549    // Parse JSON via the shared helper so the test suite exercises the
8550    // same code the JNI surface does. Pre-2026-05-08 this was inlined
8551    // here, which made it a duplicated codec the unit tests didn't
8552    // reach — the silent-field-drop bug class peat#835 exists to lock
8553    // in came in through this exact site.
8554    let node: NodeInfo = match parse_node_publish_json(&json_str) {
8555        Ok(p) => p,
8556        Err(e) => {
8557            #[cfg(target_os = "android")]
8558            android_log(&format!("publishNodeJni: {}", e));
8559            return 0; // JNI_FALSE
8560        }
8561    };
8562
8563    #[cfg(target_os = "android")]
8564    android_log(&format!(
8565        "publishNodeJni: Publishing node id={}, name={}, lat={}, lon={}",
8566        node.id, node.name, node.lat, node.lon
8567    ));
8568
8569    // Get node from handle and store node
8570    let peat_node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8571    let result = match peat_node.put_node(node) {
8572        Ok(_) => {
8573            #[cfg(target_os = "android")]
8574            android_log("publishNodeJni: Node published successfully");
8575            1 // JNI_TRUE
8576        }
8577        Err(e) => {
8578            #[cfg(target_os = "android")]
8579            android_log(&format!("publishNodeJni: Failed to publish: {:?}", e));
8580            0 // JNI_FALSE
8581        }
8582    };
8583
8584    // Don't drop the Arc - we're just borrowing
8585    std::mem::forget(peat_node);
8586
8587    result
8588}
8589
8590/// JNI: Get all markers as JSON array string
8591///
8592/// Kotlin signature: `external fun getMarkersJni(handle: Long): String`
8593/// Returns JSON array of marker objects, or `"[]"` on error.
8594#[cfg(feature = "sync")]
8595#[no_mangle]
8596pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getMarkersJni(
8597    mut env: JNIEnv,
8598    _class: JClass,
8599    handle: i64,
8600) -> jstring {
8601    if handle == 0 {
8602        return env
8603            .new_string("[]")
8604            .expect("Failed to create Java string")
8605            .into_raw();
8606    }
8607
8608    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8609    let result = match node.get_markers() {
8610        Ok(markers) => serialize_markers_get_json(&markers),
8611        Err(e) => {
8612            // Surface storage failures the same way the publish
8613            // side does — otherwise Kotlin sees `"[]"` and can't
8614            // tell "no markers" from "storage error retrieving
8615            // markers." Triage on a tablet starts with the
8616            // PeatFFI logcat tag; this line is what makes "marker
8617            // didn't sync" reports actionable.
8618            #[cfg(target_os = "android")]
8619            android_log(&format!("getMarkersJni: get_markers failed: {:?}", e));
8620            let _ = e;
8621            "[]".to_string()
8622        }
8623    };
8624
8625    // Don't drop the Arc - we're just borrowing
8626    std::mem::forget(node);
8627
8628    env.new_string(&result)
8629        .expect("Failed to create Java string")
8630        .into_raw()
8631}
8632
8633/// JNI: Publish a marker into the doc store. Routes through the
8634/// universal-Document transport on every registered radio
8635/// (LiteBridgeTranslator on BLE, iroh sync for cross-mesh peers).
8636///
8637/// Kotlin signature: `external fun publishMarkerJni(handle: Long, markerJson:
8638/// String): Boolean` Returns `1` (JNI_TRUE) on success, `0` (JNI_FALSE) on
8639/// failure (invalid handle, malformed JSON, missing required fields, storage
8640/// error). The Kotlin caller maps the boolean return back to a
8641/// success / "publish failed" log path — same shape as
8642/// `publishNodeJni`.
8643#[cfg(feature = "sync")]
8644#[no_mangle]
8645pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishMarkerJni(
8646    mut env: JNIEnv,
8647    _class: JClass,
8648    handle: i64,
8649    marker_json: JString,
8650) -> jboolean {
8651    if handle == 0 {
8652        #[cfg(target_os = "android")]
8653        android_log("publishMarkerJni: Invalid handle (0)");
8654        return 0;
8655    }
8656
8657    let json_str: String = match env.get_string(&marker_json) {
8658        Ok(s) => s.into(),
8659        Err(e) => {
8660            #[cfg(target_os = "android")]
8661            android_log(&format!(
8662                "publishMarkerJni: Failed to get JSON string: {:?}",
8663                e
8664            ));
8665            let _ = e;
8666            return 0;
8667        }
8668    };
8669
8670    #[cfg(target_os = "android")]
8671    android_log(&format!("publishMarkerJni: Received JSON: {}", json_str));
8672
8673    // Parse — uid is read from the body (no doc-store id available
8674    // pre-storage). parse_marker_publish_json's `id` parameter is
8675    // accepted for the scan-side path; on publish we pass the
8676    // body's uid and reject if absent.
8677    let marker: MarkerInfo = match parse_marker_publish_json("", &json_str) {
8678        Ok(m) => m,
8679        Err(e) => {
8680            #[cfg(target_os = "android")]
8681            android_log(&format!("publishMarkerJni: parse error: {:?}", e));
8682            let _ = e;
8683            return 0;
8684        }
8685    };
8686
8687    #[cfg(target_os = "android")]
8688    if marker.deleted {
8689        android_log(&format!(
8690            "publishMarkerJni: Publishing TOMBSTONE for uid={}",
8691            marker.uid
8692        ));
8693    } else {
8694        android_log(&format!(
8695            "publishMarkerJni: Publishing marker uid={}, type={}, lat={}, lon={}",
8696            marker.uid, marker.marker_type, marker.lat, marker.lon
8697        ));
8698    }
8699
8700    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8701    let result = match node.put_marker(marker) {
8702        Ok(_) => {
8703            #[cfg(target_os = "android")]
8704            android_log("publishMarkerJni: Marker published successfully");
8705            1
8706        }
8707        Err(e) => {
8708            #[cfg(target_os = "android")]
8709            android_log(&format!("publishMarkerJni: Failed to publish: {:?}", e));
8710            let _ = e;
8711            0
8712        }
8713    };
8714
8715    std::mem::forget(node);
8716    result
8717}
8718
8719/// Publish a generic document into a named collection via `peat_mesh::Node`.
8720///
8721/// JNI wrapper around [`publish_document_into_node`]. The Kotlin caller passes
8722/// a JSON object; top-level keys become the document body. The `"id"` field
8723/// is optional — when present and a string, it becomes the document's id;
8724/// when absent or non-string, the backend assigns one (and returns it). The
8725/// returned Java string is the id that was actually used (caller-supplied or
8726/// backend-assigned), so callers needing a stable id must capture the return
8727/// value rather than assuming the input `"id"` won.
8728///
8729/// Returns an empty Java string on failure: handle invalid, JSON malformed,
8730/// JSON not an object, or backend publish error. Foundation step 3 of the
8731/// peat-mesh-completion work.
8732///
8733/// Kotlin signature: `external fun publishDocumentJni(handle: Long, collection:
8734/// String, json: String): String`
8735#[cfg(feature = "sync")]
8736#[no_mangle]
8737pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishDocumentJni(
8738    mut env: JNIEnv,
8739    _class: JClass,
8740    handle: i64,
8741    collection: JString,
8742    json: JString,
8743) -> jstring {
8744    // Track the result string we want to return; build the jstring at the
8745    // single env.new_string() call site at the end. Avoids the tangle of
8746    // borrowing `env` multiple times across short-circuit error returns.
8747    let result_str: String = if handle == 0 {
8748        #[cfg(target_os = "android")]
8749        android_log("publishDocumentJni: Invalid handle (0)");
8750        String::new()
8751    } else {
8752        match (env.get_string(&collection), env.get_string(&json)) {
8753            (Ok(c), Ok(j)) => {
8754                let collection_str: String = c.into();
8755                let json_str: String = j.into();
8756                // Borrow the node Arc without taking ownership — same
8757                // pattern as the other ..._Jni functions in this file.
8758                let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
8759                let mesh_node = Arc::clone(&node_owner.node);
8760                let runtime = Arc::clone(&node_owner.runtime);
8761                std::mem::forget(node_owner);
8762
8763                // clippy suggests `.unwrap_or_default()` but the Err
8764                // arm has a real side effect (android_log call) that
8765                // would be lost.
8766                #[allow(clippy::manual_unwrap_or_default)]
8767                match runtime.block_on(publish_document_into_node(
8768                    &mesh_node,
8769                    &collection_str,
8770                    &json_str,
8771                )) {
8772                    Ok(id) => id,
8773                    Err(_e) => {
8774                        #[cfg(target_os = "android")]
8775                        android_log(&format!("publishDocumentJni: publish failed: {}", _e));
8776                        String::new()
8777                    }
8778                }
8779            }
8780            (Err(_e), _) | (_, Err(_e)) => {
8781                #[cfg(target_os = "android")]
8782                android_log(&format!(
8783                    "publishDocumentJni: failed to read args: {:?}",
8784                    _e
8785                ));
8786                String::new()
8787            }
8788        }
8789    };
8790
8791    env.new_string(result_str)
8792        .map(|s| s.into_raw())
8793        .unwrap_or(std::ptr::null_mut())
8794}
8795
8796/// Origin-aware sibling of [`Java_..._publishDocumentJni`]
8797/// (ADR-059 Amendment 2 — Slice 1.b.4 host-side wiring).
8798///
8799/// Same body as `publishDocumentJni` plus an `origin` parameter that
8800/// flows through to [`peat_mesh::Node::publish_with_origin`]. The
8801/// plugin's `BleDecodedDocumentBridge` calls this with `origin="ble"`
8802/// after decoding a 0xB6 translator frame, so cross-transport fan-out's
8803/// loop-prevention skips the BLE channel on this node and the doc
8804/// doesn't re-emit back out the way it came.
8805///
8806/// Empty `origin` is treated as `None` (equivalent to plain
8807/// `publishDocumentJni`); any non-empty string is passed through
8808/// verbatim. peat-mesh validates the origin against the registered
8809/// transport set; an unknown origin produces a publish-time error
8810/// (logged + empty return string).
8811///
8812/// Kotlin signature: `external fun publishDocumentWithOriginJni(handle: Long,
8813/// collection: String, json: String, origin: String): String`
8814#[cfg(feature = "sync")]
8815#[no_mangle]
8816pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishDocumentWithOriginJni(
8817    mut env: JNIEnv,
8818    _class: JClass,
8819    handle: i64,
8820    collection: JString,
8821    json: JString,
8822    origin: JString,
8823) -> jstring {
8824    let result_str: String = if handle == 0 {
8825        #[cfg(target_os = "android")]
8826        android_log("publishDocumentWithOriginJni: Invalid handle (0)");
8827        String::new()
8828    } else {
8829        match (
8830            env.get_string(&collection),
8831            env.get_string(&json),
8832            env.get_string(&origin),
8833        ) {
8834            (Ok(c), Ok(j), Ok(o)) => {
8835                let collection_str: String = c.into();
8836                let json_str: String = j.into();
8837                let origin_str: String = o.into();
8838                let origin_opt = if origin_str.is_empty() {
8839                    None
8840                } else {
8841                    Some(origin_str)
8842                };
8843                let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
8844                let mesh_node = Arc::clone(&node_owner.node);
8845                let runtime = Arc::clone(&node_owner.runtime);
8846                std::mem::forget(node_owner);
8847
8848                #[allow(clippy::manual_unwrap_or_default)]
8849                match runtime.block_on(publish_document_into_node_with_origin(
8850                    &mesh_node,
8851                    &collection_str,
8852                    &json_str,
8853                    origin_opt,
8854                )) {
8855                    Ok(id) => id,
8856                    Err(_e) => {
8857                        #[cfg(target_os = "android")]
8858                        android_log(&format!(
8859                            "publishDocumentWithOriginJni: publish failed: {}",
8860                            _e
8861                        ));
8862                        String::new()
8863                    }
8864                }
8865            }
8866            // Per-position match preserves the underlying JNI error in
8867            // the diagnostic, matching `publishDocumentJni`'s shape. A
8868            // wildcard arm would drop `_e` and obscure plugin-side
8869            // debugging when one of the three string args is malformed.
8870            (Err(_e), _, _) | (_, Err(_e), _) | (_, _, Err(_e)) => {
8871                #[cfg(target_os = "android")]
8872                android_log(&format!(
8873                    "publishDocumentWithOriginJni: failed to read args: {:?}",
8874                    _e
8875                ));
8876                String::new()
8877            }
8878        }
8879    };
8880
8881    env.new_string(result_str)
8882        .map(|s| s.into_raw())
8883        .unwrap_or(std::ptr::null_mut())
8884}
8885
8886/// Pure-Rust helper backing [`Java_..._publishDocumentJni`]. Parses a JSON
8887/// object into a [`peat_mesh::sync::types::Document`] (the `"id"` string
8888/// field, if present, becomes [`Document::id`]; remaining keys land in
8889/// [`Document::fields`]) and publishes it into the given collection on the
8890/// node. Exposed for unit tests so the conversion + publish path can be
8891/// exercised without spinning up a JVM.
8892#[cfg(feature = "sync")]
8893async fn publish_document_into_node(
8894    node: &peat_mesh::Node,
8895    collection: &str,
8896    json: &str,
8897) -> anyhow::Result<String> {
8898    publish_document_into_node_with_origin(node, collection, json, None).await
8899}
8900
8901/// Origin-aware sibling of [`publish_document_into_node`], backing
8902/// [`Java_..._publishDocumentWithOriginJni`] (ADR-059 Amendment 2 Slice
8903/// 1.b.4). When `origin` is `Some(_)`, publishes via
8904/// [`peat_mesh::Node::publish_with_origin`] so cross-transport fan-out's
8905/// loop-prevention skips the named origin transport — required for the
8906/// plugin's `BleDecodedDocumentBridge` to ingest 0xB6 frames into the
8907/// doc store without re-emitting them back out to BLE. With `None` this
8908/// behaves identically to a plain `publish`. Exposed for unit tests so
8909/// the parse + publish-with-origin path can be exercised without a JVM.
8910#[cfg(feature = "sync")]
8911async fn publish_document_into_node_with_origin(
8912    node: &peat_mesh::Node,
8913    collection: &str,
8914    json: &str,
8915    origin: Option<String>,
8916) -> anyhow::Result<String> {
8917    use peat_mesh::sync::types::Document;
8918    use serde_json::Value;
8919
8920    let value: Value =
8921        serde_json::from_str(json).map_err(|e| anyhow::anyhow!("invalid JSON: {}", e))?;
8922
8923    let mut obj = match value {
8924        Value::Object(map) => map,
8925        other => {
8926            return Err(anyhow::anyhow!(
8927                "document JSON must be an object, got {:?}",
8928                other
8929            ))
8930        }
8931    };
8932
8933    let id = obj.remove("id").and_then(|v| match v {
8934        Value::String(s) => Some(s),
8935        _ => None,
8936    });
8937
8938    let fields = obj.into_iter().collect();
8939    let document = match id {
8940        Some(id) => Document::with_id(id, fields),
8941        None => Document::new(fields),
8942    };
8943
8944    match origin {
8945        Some(o) => {
8946            node.publish_with_origin(collection, document, Some(o))
8947                .await
8948        }
8949        None => node.publish(collection, document).await,
8950    }
8951}
8952
8953/// Ingest a peat-btle [`BlePosition`]-shaped JSON envelope: translate it
8954/// to an Automerge track document via [`BleTranslator`] and publish into
8955/// [`peat_mesh::Node`] with `Some("ble")` origin (ADR-059). From there
8956/// iroh-bound peers receive the doc through Automerge sync; the origin
8957/// rides on the resulting `ChangeEvent` so `TransportManager`'s fan-out
8958/// suppresses the same-node `BLE → Node → observer → BLE` echo.
8959///
8960/// JSON envelope (matches the `BlePosition` field shape plus the surrounding
8961/// metadata the translator needs):
8962/// ```json
8963/// {
8964///   "lat": 40.7,
8965///   "lon": -74.0,
8966///   "altitude": 100.0,        // optional
8967///   "accuracy": 5.0,          // optional
8968///   "peripheral_id": 3405643777,
8969///   "callsign": "SCOUT-CAFE", // optional
8970///   "mesh_id": "29C916FA"     // optional
8971/// }
8972/// ```
8973///
8974/// `peripheral_id` accepts the full u32 range expressed two ways: as a
8975/// non-negative integer (Kotlin `Long`/`UInt` paths) or as a sign-extended
8976/// negative integer (Kotlin `Int.toLong()` of a u32 with the high bit set —
8977/// e.g. `0xCAFE_0001` reads as `-889323519` through a signed Int). Both forms
8978/// recover the same u32 internally; values above `u32::MAX` or below
8979/// `i32::MIN` are rejected rather than silently truncated. See
8980/// [`parse_peripheral_id`].
8981///
8982/// Kotlin signature: `external fun ingestPositionJni(handle: Long, json:
8983/// String): String`
8984///
8985/// Returns the assigned track-document id on success, or empty string on any
8986/// failure (handle invalid, bluetooth feature not built, JSON malformed,
8987/// missing required fields, peripheral_id out of range, publish error).
8988///
8989/// [`BleTranslator`]: peat_protocol::sync::ble_translation::BleTranslator
8990#[cfg(all(feature = "sync", feature = "bluetooth"))]
8991#[no_mangle]
8992pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestPositionJni(
8993    mut env: JNIEnv,
8994    _class: JClass,
8995    handle: i64,
8996    json: JString,
8997) -> jstring {
8998    let result_str: String = if handle == 0 {
8999        #[cfg(target_os = "android")]
9000        android_log("ingestPositionJni: Invalid handle (0)");
9001        String::new()
9002    } else {
9003        match env.get_string(&json) {
9004            Ok(j) => {
9005                let json_str: String = j.into();
9006                let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
9007                let translator = Arc::clone(&node_owner.ble_translator);
9008                let node = Arc::clone(&node_owner.node);
9009                let runtime = Arc::clone(&node_owner.runtime);
9010                std::mem::forget(node_owner);
9011
9012                // The Err arm has a side effect (android_log) that
9013                // `unwrap_or_default()` cannot preserve, so the `match`
9014                // is intentional. Keeping the lint silenced explicitly
9015                // mirrors the same decision in pre-Slice-1.b.2.2 code.
9016                #[allow(clippy::manual_unwrap_or_default)]
9017                match runtime.block_on(ingest_position_via_translator(
9018                    &translator,
9019                    &node,
9020                    &json_str,
9021                )) {
9022                    Ok(id) => id,
9023                    Err(_e) => {
9024                        #[cfg(target_os = "android")]
9025                        android_log(&format!("ingestPositionJni: ingest failed: {}", _e));
9026                        String::new()
9027                    }
9028                }
9029            }
9030            Err(_e) => {
9031                #[cfg(target_os = "android")]
9032                android_log(&format!("ingestPositionJni: failed to read json: {:?}", _e));
9033                String::new()
9034            }
9035        }
9036    };
9037
9038    env.new_string(result_str)
9039        .map(|s| s.into_raw())
9040        .unwrap_or(std::ptr::null_mut())
9041}
9042
9043/// JNI: Ingest an inbound frame received over BLE into the mesh.
9044///
9045/// Kotlin signature:
9046/// `external fun ingestInboundFrameJni(handle: Long, collection: String,
9047/// postcardBytes: ByteArray): String?`
9048///
9049/// Thin wrapper over [`PeatNode::ingest_inbound_frame`], which decodes the
9050/// frame via the `BleTranslator` and publishes it into the mesh tagged with
9051/// `Some("ble")` origin — so `TransportManager`'s per-transport fan-out
9052/// re-emits it to the OTHER transports (iroh / Wi-Fi) without looping back
9053/// to BLE (ADR-059). This is the inbound counterpart of
9054/// `subscribeOutboundFramesJni`: a Kotlin BLE manager calls this with each
9055/// decrypted frame it receives over the radio.
9056///
9057/// Returns the published document id, or null on failure / no-op (invalid
9058/// handle, byte/string marshaling error, or the translator produced no
9059/// document).
9060#[cfg(all(feature = "sync", feature = "bluetooth"))]
9061#[no_mangle]
9062pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestInboundFrameJni(
9063    mut env: JNIEnv,
9064    _class: JClass,
9065    handle: i64,
9066    collection: JString,
9067    postcard_bytes: JByteArray,
9068) -> jstring {
9069    if handle == 0 {
9070        #[cfg(target_os = "android")]
9071        android_log("ingestInboundFrameJni: Invalid handle (0)");
9072        return std::ptr::null_mut();
9073    }
9074    let collection_str: String = match env.get_string(&collection) {
9075        Ok(s) => s.into(),
9076        Err(_e) => {
9077            #[cfg(target_os = "android")]
9078            android_log(&format!(
9079                "ingestInboundFrameJni: failed to read collection: {:?}",
9080                _e
9081            ));
9082            return std::ptr::null_mut();
9083        }
9084    };
9085    let bytes: Vec<u8> = match env.convert_byte_array(&postcard_bytes) {
9086        Ok(b) => b,
9087        Err(_e) => {
9088            #[cfg(target_os = "android")]
9089            android_log(&format!(
9090                "ingestInboundFrameJni: failed to read bytes: {:?}",
9091                _e
9092            ));
9093            return std::ptr::null_mut();
9094        }
9095    };
9096
9097    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
9098    let result = node_owner.ingest_inbound_frame(collection_str, bytes);
9099    std::mem::forget(node_owner);
9100
9101    match result {
9102        Ok(Some(id)) => env
9103            .new_string(id)
9104            .map(|s| s.into_raw())
9105            .unwrap_or(std::ptr::null_mut()),
9106        Ok(None) => std::ptr::null_mut(),
9107        Err(_e) => {
9108            #[cfg(target_os = "android")]
9109            android_log(&format!("ingestInboundFrameJni: ingest failed: {}", _e));
9110            std::ptr::null_mut()
9111        }
9112    }
9113}
9114
9115/// JNI: Ingest an inbound BLE frame on the universal-Document (peat-lite /
9116/// `ble-lite`) codec — the counterpart of `ingestInboundFrameJni` for raw
9117/// collections the typed translator declines (e.g. the `demo` counter).
9118///
9119/// Kotlin signature:
9120/// `external fun ingestInboundLiteFrameJni(handle: Long, collection: String,
9121/// envelopeBytes: ByteArray): String?`
9122#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
9123#[no_mangle]
9124pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestInboundLiteFrameJni(
9125    mut env: JNIEnv,
9126    _class: JClass,
9127    handle: i64,
9128    collection: JString,
9129    envelope_bytes: JByteArray,
9130) -> jstring {
9131    if handle == 0 {
9132        #[cfg(target_os = "android")]
9133        android_log("ingestInboundLiteFrameJni: Invalid handle (0)");
9134        return std::ptr::null_mut();
9135    }
9136    let collection_str: String = match env.get_string(&collection) {
9137        Ok(s) => s.into(),
9138        Err(_e) => {
9139            #[cfg(target_os = "android")]
9140            android_log(&format!(
9141                "ingestInboundLiteFrameJni: failed to read collection: {:?}",
9142                _e
9143            ));
9144            return std::ptr::null_mut();
9145        }
9146    };
9147    let bytes: Vec<u8> = match env.convert_byte_array(&envelope_bytes) {
9148        Ok(b) => b,
9149        Err(_e) => {
9150            #[cfg(target_os = "android")]
9151            android_log(&format!(
9152                "ingestInboundLiteFrameJni: failed to read bytes: {:?}",
9153                _e
9154            ));
9155            return std::ptr::null_mut();
9156        }
9157    };
9158
9159    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
9160    let result = node_owner.ingest_inbound_lite_frame(collection_str, bytes);
9161    std::mem::forget(node_owner);
9162
9163    match result {
9164        Ok(Some(id)) => env
9165            .new_string(id)
9166            .map(|s| s.into_raw())
9167            .unwrap_or(std::ptr::null_mut()),
9168        Ok(None) => std::ptr::null_mut(),
9169        Err(_e) => {
9170            #[cfg(target_os = "android")]
9171            android_log(&format!("ingestInboundLiteFrameJni: ingest failed: {}", _e));
9172            std::ptr::null_mut()
9173        }
9174    }
9175}
9176
9177/// JNI: ingest an inbound CRDT-counter frame (CRDT-over-Automerge-over-BLE).
9178///
9179/// `hex_bytes` is the UTF-8 hex of the shared Automerge doc's `save()` bytes —
9180/// the payload of a `0xAF` frame whose transport byte is `2` (crdt). Merges it
9181/// into the shared counter (idempotent/commutative) and returns the new value,
9182/// or -1 on error. Operates on the SAME `PeatNode` Dart created (the global
9183/// handle is an owning alias), so Dart's `crdtCounterValue()` sees the result.
9184///
9185/// Routes by `collection`: `"supply"` merges the Counter (returns the new
9186/// value); any other collection merges the generic CRDT KV doc (returns 0).
9187/// Returns -1 on error.
9188///
9189/// Kotlin: `external fun ingestCrdtFrameJni(handle: Long, collection: String,
9190/// hexBytes: ByteArray): Long`
9191#[cfg(all(feature = "sync", feature = "bluetooth"))]
9192#[no_mangle]
9193pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestCrdtFrameJni(
9194    mut env: JNIEnv,
9195    _class: JClass,
9196    handle: i64,
9197    collection: JString,
9198    hex_bytes: JByteArray,
9199) -> i64 {
9200    if handle == 0 {
9201        return -1;
9202    }
9203    let collection_str: String = match env.get_string(&collection) {
9204        Ok(s) => s.into(),
9205        Err(_) => return -1,
9206    };
9207    let bytes: Vec<u8> = match env.convert_byte_array(&hex_bytes) {
9208        Ok(b) => b,
9209        Err(_) => return -1,
9210    };
9211    let hex = match String::from_utf8(bytes) {
9212        Ok(s) => s,
9213        Err(_) => return -1,
9214    };
9215    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
9216    let v = if collection_str == "supply" {
9217        node_owner.crdt_counter_merge(hex)
9218    } else {
9219        node_owner.crdt_kv_merge(collection_str, hex);
9220        0
9221    };
9222    std::mem::forget(node_owner);
9223    v
9224}
9225
9226/// Pure-Rust helper backing [`Java_..._ingestPositionJni`]. Parses the JSON
9227/// envelope into a [`BlePosition`] plus the surrounding ingest metadata,
9228/// translates to an Automerge document via [`BleTranslator`], and publishes
9229/// into [`peat_mesh::Node`] with `Some("ble")` origin per ADR-059. Exposed
9230/// for unit tests so the parse + translate + publish path can be exercised
9231/// without spinning up a JVM.
9232///
9233/// Hand-rolled JSON parsing rather than `#[derive(Deserialize)]` because
9234/// peat-ffi does not currently depend on `serde` directly (only
9235/// `serde_json`); adding it just for one private marshaling struct isn't
9236/// worth a Cargo.toml change and a fresh transitive footprint.
9237///
9238/// [`BlePosition`]: peat_protocol::sync::ble_translation::BlePosition
9239/// [`BleTranslator`]: peat_protocol::sync::ble_translation::BleTranslator
9240#[cfg(all(feature = "sync", feature = "bluetooth"))]
9241async fn ingest_position_via_translator(
9242    translator: &peat_protocol::sync::ble_translation::BleTranslator,
9243    node: &peat_mesh::Node,
9244    json: &str,
9245) -> anyhow::Result<String> {
9246    use peat_protocol::sync::ble_translation::{value_to_mesh_document, BlePosition};
9247    use serde_json::Value;
9248
9249    let value: Value = serde_json::from_str(json)
9250        .map_err(|e| anyhow::anyhow!("invalid ingest-position JSON: {}", e))?;
9251    let obj = value
9252        .as_object()
9253        .ok_or_else(|| anyhow::anyhow!("ingest-position JSON must be an object"))?;
9254
9255    let lat = obj
9256        .get("lat")
9257        .and_then(Value::as_f64)
9258        .ok_or_else(|| anyhow::anyhow!("ingest-position: missing or non-numeric `lat`"))?
9259        as f32;
9260    let lon = obj
9261        .get("lon")
9262        .and_then(Value::as_f64)
9263        .ok_or_else(|| anyhow::anyhow!("ingest-position: missing or non-numeric `lon`"))?
9264        as f32;
9265    let peripheral_id = parse_peripheral_id(obj.get("peripheral_id"))?;
9266
9267    let altitude = obj
9268        .get("altitude")
9269        .and_then(Value::as_f64)
9270        .map(|v| v as f32);
9271    let accuracy = obj
9272        .get("accuracy")
9273        .and_then(Value::as_f64)
9274        .map(|v| v as f32);
9275    let callsign = obj
9276        .get("callsign")
9277        .and_then(Value::as_str)
9278        .map(str::to_string);
9279    let mesh_id = obj
9280        .get("mesh_id")
9281        .and_then(Value::as_str)
9282        .map(str::to_string);
9283
9284    let position = BlePosition {
9285        latitude: lat,
9286        longitude: lon,
9287        altitude,
9288        accuracy,
9289    };
9290
9291    // Translate, then publish through Node::publish_with_origin so the
9292    // `Some("ble")` origin rides on the resulting ChangeEvent — without
9293    // it, TransportManager fan-out cannot break the BLE-loop on this
9294    // node (ADR-059 §"Origin propagation through async observer
9295    // pipelines").
9296    let value = translator.position_to_track_in_cell(
9297        &position,
9298        peripheral_id,
9299        callsign.as_deref(),
9300        mesh_id.as_deref(),
9301    );
9302    let doc = value_to_mesh_document(value);
9303    node.publish_with_origin(translator.tracks_collection(), doc, Some("ble".to_string()))
9304        .await
9305}
9306
9307/// Parse a `peripheral_id` JSON value into a `u32`, accepting both the
9308/// positive form (Kotlin `Long` / `UInt`) and the sign-extended-Int form
9309/// (Kotlin `Int.toLong()` of a value with the high bit set, which serializes
9310/// as a negative JSON literal). Reinterprets the bits via `i32 as u32` for
9311/// the negative case so a watch with peripheral_id `0xCAFE_0001` round-trips
9312/// the same regardless of which Kotlin numeric type the caller used.
9313///
9314/// Rejects missing values, non-integer values, and values outside
9315/// `[i32::MIN, u32::MAX]` (above-u32::MAX would otherwise silently truncate
9316/// and collide distinct logical IDs onto the same translator-emitted track
9317/// id `ble-XXXXXXXX`, mis-attributing positions to peers — caught by PR
9318/// #804 round-1 review).
9319#[cfg(all(feature = "sync", feature = "bluetooth"))]
9320fn parse_peripheral_id(value: Option<&serde_json::Value>) -> anyhow::Result<u32> {
9321    let raw = value.and_then(serde_json::Value::as_i64).ok_or_else(|| {
9322        anyhow::anyhow!("ingest-position: missing or non-integer `peripheral_id`")
9323    })?;
9324
9325    if (0..=u32::MAX as i64).contains(&raw) {
9326        // Positive: Kotlin Long, UInt, or any numeric type that produced a
9327        // non-negative JSON literal. Direct cast — no truncation since we
9328        // bounded above.
9329        Ok(raw as u32)
9330    } else if (i32::MIN as i64..=-1).contains(&raw) {
9331        // Negative: Kotlin Int.toLong() of a u32 with the high bit set
9332        // (e.g. 0xCAFE_0001 = 3_405_643_777 stored in a signed Int reads as
9333        // -889_323_519). `as i32` preserves the bit pattern, then
9334        // `as u32` reinterprets — so the recovered u32 matches what the
9335        // caller's u32 originally was, before Kotlin's signed-Int coercion.
9336        Ok((raw as i32) as u32)
9337    } else {
9338        Err(anyhow::anyhow!(
9339            "ingest-position: `peripheral_id` {} out of u32 range \
9340             (accepts [i32::MIN, u32::MAX] to handle both Kotlin Int and Long callers)",
9341            raw
9342        ))
9343    }
9344}
9345
9346/// Connect to a known peer by node ID and address (bypasses mDNS).
9347///
9348/// Kotlin signature: external fun connectPeerJni(handle: Long, nodeId: String,
9349/// address: String): Boolean Used by the dual-transport test to connect Android
9350/// to rpi-ci2 over QUIC when mDNS is unreliable.
9351#[cfg(feature = "sync")]
9352#[no_mangle]
9353pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_connectPeerJni(
9354    mut env: JNIEnv,
9355    _class: JClass,
9356    handle: i64,
9357    node_id: JString,
9358    address: JString,
9359) -> jboolean {
9360    if handle == 0 {
9361        #[cfg(target_os = "android")]
9362        android_log("connectPeerJni: Invalid handle (0)");
9363        return 0;
9364    }
9365
9366    let node_id_str: String = match env.get_string(&node_id) {
9367        Ok(s) => s.into(),
9368        Err(e) => {
9369            #[cfg(target_os = "android")]
9370            android_log(&format!("connectPeerJni: Failed to get nodeId: {:?}", e));
9371            return 0;
9372        }
9373    };
9374
9375    let addr_str: String = match env.get_string(&address) {
9376        Ok(s) => s.into(),
9377        Err(e) => {
9378            #[cfg(target_os = "android")]
9379            android_log(&format!("connectPeerJni: Failed to get address: {:?}", e));
9380            return 0;
9381        }
9382    };
9383
9384    #[cfg(target_os = "android")]
9385    android_log(&format!(
9386        "connectPeerJni: Connecting to node={}, addr={}",
9387        node_id_str, addr_str
9388    ));
9389
9390    let peer_info = PeerInfo {
9391        name: "quic-peer".to_string(),
9392        node_id: node_id_str,
9393        addresses: vec![addr_str],
9394        relay_url: None,
9395    };
9396
9397    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
9398    let result = match node.connect_peer(peer_info) {
9399        Ok(()) => {
9400            #[cfg(target_os = "android")]
9401            android_log("connectPeerJni: Connected successfully");
9402            1
9403        }
9404        Err(e) => {
9405            #[cfg(target_os = "android")]
9406            android_log(&format!("connectPeerJni: Failed to connect: {:?}", e));
9407            0
9408        }
9409    };
9410
9411    std::mem::forget(node);
9412    result
9413}
9414
9415// =============================================================================
9416// Document Change Subscription (direct JNI path)
9417// =============================================================================
9418//
9419// This is the push-based equivalent of the UniFFI PeatNode::subscribe() API.
9420// We can't use UniFFI's version from Android plugin consumers because UniFFI
9421// 0.28's Kotlin backend generates callback interfaces that inherit from
9422// com.sun.jna.Callback, and JNA's function-pointer resolution fails under
9423// Android plugin-host linker namespace isolation (see the comment block at
9424// the top of the JNI Bindings section and ADR-059 for full context).
9425//
9426// The direct-JNI path uses the same JAVA_VM + GlobalRef + attach_current_thread
9427// pattern that notify_peer_event already uses for peer connectivity events.
9428// Only one subscription is supported at a time.
9429
9430/// JNI: Subscribe to document change notifications
9431///
9432/// Kotlin signature:
9433/// `external fun subscribeDocumentChangesJni(handle: Long, listener:
9434/// DocumentChangeListener): Boolean`
9435///
9436/// The listener receives `onChange(collection, docId)` for every document
9437/// upsert and `onError(message)` if the underlying broadcast channel lags or
9438/// closes. Calls from the Rust side happen on the tokio runtime thread owned by
9439/// the PeatNode; the listener must be safe to invoke from any thread (consumers
9440/// typically post back to a main-thread Handler before touching UI state).
9441///
9442/// Replacing an existing subscription is allowed: the previous listener's
9443/// GlobalRef is dropped and the new one takes over on the next event.
9444#[cfg(feature = "sync")]
9445#[no_mangle]
9446pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_subscribeDocumentChangesJni(
9447    mut env: JNIEnv,
9448    _class: JClass,
9449    handle: i64,
9450    listener: jni::objects::JObject,
9451) -> jboolean {
9452    use std::sync::atomic::Ordering;
9453
9454    if handle == 0 {
9455        #[cfg(target_os = "android")]
9456        android_log("subscribeDocumentChangesJni: Invalid handle (0)");
9457        return 0;
9458    }
9459
9460    // Stash the listener as a global reference so it survives across JNI
9461    // thread attaches and isn't GC'd out from under us.
9462    let listener_global = match env.new_global_ref(&listener) {
9463        Ok(g) => g,
9464        Err(e) => {
9465            #[cfg(target_os = "android")]
9466            android_log(&format!(
9467                "subscribeDocumentChangesJni: new_global_ref failed: {:?}",
9468                e
9469            ));
9470            return 0;
9471        }
9472    };
9473
9474    // Swap the listener in; drop any previous one.
9475    {
9476        let mut slot = DOCUMENT_CHANGE_LISTENER.lock().unwrap();
9477        *slot = Some(listener_global);
9478    }
9479
9480    // Signal the previous subscription task (if any) to exit before we start
9481    // a new one, then mark the new subscription active.
9482    DOCUMENT_SUBSCRIPTION_ACTIVE.store(false, Ordering::SeqCst);
9483    DOCUMENT_SUBSCRIPTION_ACTIVE.store(true, Ordering::SeqCst);
9484
9485    // Borrow the node without taking ownership of its Arc.
9486    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
9487    let store = Arc::clone(&node.store);
9488    let runtime = Arc::clone(&node.runtime);
9489    std::mem::forget(node);
9490
9491    runtime.spawn(async move {
9492        let mut rx = store.subscribe_to_changes();
9493        while DOCUMENT_SUBSCRIPTION_ACTIVE.load(Ordering::SeqCst) {
9494            tokio::select! {
9495                result = rx.recv() => {
9496                    match result {
9497                        Ok(doc_key) => {
9498                            let (collection, doc_id) = doc_key
9499                                .split_once(':')
9500                                .map(|(c, d)| (c.to_string(), d.to_string()))
9501                                .unwrap_or_else(|| ("default".to_string(), doc_key.clone()));
9502                            dispatch_document_change(&collection, &doc_id);
9503                        }
9504                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
9505                            dispatch_document_error(&format!("lagged {} messages", n));
9506                        }
9507                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
9508                            dispatch_document_error("change channel closed");
9509                            break;
9510                        }
9511                    }
9512                }
9513                _ = tokio::time::sleep(tokio::time::Duration::from_millis(200)) => {
9514                    // Periodic wake so we notice unsubscribe requests even
9515                    // when the broadcast channel is quiet.
9516                }
9517            }
9518        }
9519
9520        // On exit, drop the listener ref if we were the owning subscription.
9521        if !DOCUMENT_SUBSCRIPTION_ACTIVE.load(Ordering::SeqCst) {
9522            let mut slot = DOCUMENT_CHANGE_LISTENER.lock().unwrap();
9523            *slot = None;
9524        }
9525    });
9526
9527    1 // JNI_TRUE
9528}
9529
9530/// JNI: Unsubscribe from document change notifications
9531///
9532/// Kotlin signature: `external fun unsubscribeDocumentChangesJni()`
9533///
9534/// Signals the background subscription task to exit on its next iteration.
9535/// The listener GlobalRef is dropped by the task on exit (not here) to avoid
9536/// a race between unsubscribe and an in-flight dispatch.
9537#[cfg(feature = "sync")]
9538#[no_mangle]
9539pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_unsubscribeDocumentChangesJni(
9540    _env: JNIEnv,
9541    _class: JClass,
9542) {
9543    use std::sync::atomic::Ordering;
9544    DOCUMENT_SUBSCRIPTION_ACTIVE.store(false, Ordering::SeqCst);
9545    #[cfg(target_os = "android")]
9546    android_log("unsubscribeDocumentChangesJni: subscription marked inactive");
9547}
9548
9549/// Snapshot the listener `GlobalRef` from a static slot under its mutex,
9550/// returning a clone that the caller can use without holding the lock.
9551///
9552/// Pulling the lock-acquire/clone/drop dance into a helper keeps every
9553/// dispatch helper above honest about not holding a listener lock across a
9554/// re-entrant JNI `call_method` (QA #808 IDIOM).
9555#[cfg(feature = "sync")]
9556fn clone_listener(slot: &Mutex<Option<GlobalRef>>) -> Option<GlobalRef> {
9557    slot.lock().ok()?.as_ref().cloned()
9558}
9559
9560/// Reconstruct a process-global `JavaVM` from `JAVA_VM` without holding the
9561/// mutex past the read. The underlying pointer is stable for the JVM
9562/// lifetime, so dropping the lock and re-wrapping is safe — and it lets
9563/// JNI calls in dispatch helpers proceed without serializing on `JAVA_VM`.
9564#[cfg(feature = "sync")]
9565fn clone_java_vm() -> Option<jni::JavaVM> {
9566    let raw_ptr = {
9567        let guard = JAVA_VM.lock().ok()?;
9568        guard.as_ref()?.get_java_vm_pointer()
9569    };
9570    // SAFETY: JNI_OnLoad seeded JAVA_VM via `JavaVM::from_raw`, so the
9571    // pointer points at a live `sys::JavaVM` for the rest of the process.
9572    // `JavaVM` has no `Drop` impl — wrapping the same pointer twice does
9573    // not double-free.
9574    unsafe { jni::JavaVM::from_raw(raw_ptr) }.ok()
9575}
9576
9577/// Dispatch a document-change event to the registered Kotlin listener.
9578/// Attaches the current tokio worker thread to the JVM if needed.
9579#[cfg(feature = "sync")]
9580fn dispatch_document_change(collection: &str, doc_id: &str) {
9581    // Snapshot the listener and JavaVM pointer under their locks, then drop
9582    // the guards BEFORE the unbounded JNI `call_method` (QA #808 IDIOM).
9583    // Kotlin's `onChange` may re-enter Rust JNI; holding either lock across
9584    // the call would deadlock the listener slot (re-entrant lock) or
9585    // serialize every translator's dispatch through a single JVM call.
9586    // GlobalRef is Arc-shaped so cloning is cheap; JavaVM is process-stable
9587    // so reconstructing from the raw pointer is sound.
9588    let Some(listener) = clone_listener(&DOCUMENT_CHANGE_LISTENER) else {
9589        return;
9590    };
9591    let Some(java_vm) = clone_java_vm() else {
9592        return;
9593    };
9594
9595    let mut env = match java_vm.attach_current_thread() {
9596        Ok(e) => e,
9597        Err(e) => {
9598            #[cfg(target_os = "android")]
9599            android_log(&format!("dispatch_document_change: attach failed: {:?}", e));
9600            let _ = e;
9601            return;
9602        }
9603    };
9604
9605    let collection_jstr = match env.new_string(collection) {
9606        Ok(s) => s,
9607        Err(_) => return,
9608    };
9609    let doc_id_jstr = match env.new_string(doc_id) {
9610        Ok(s) => s,
9611        Err(_) => return,
9612    };
9613
9614    if let Err(e) = env.call_method(
9615        &listener,
9616        "onChange",
9617        "(Ljava/lang/String;Ljava/lang/String;)V",
9618        &[
9619            JValue::Object(&collection_jstr),
9620            JValue::Object(&doc_id_jstr),
9621        ],
9622    ) {
9623        #[cfg(target_os = "android")]
9624        android_log(&format!(
9625            "dispatch_document_change: call_method failed: {:?}",
9626            e
9627        ));
9628        let _ = e;
9629        let _ = env.exception_describe();
9630        let _ = env.exception_clear();
9631    }
9632}
9633
9634/// Dispatch an error message to the registered Kotlin listener.
9635#[cfg(feature = "sync")]
9636fn dispatch_document_error(message: &str) {
9637    // Snapshot then drop locks before JNI work — see dispatch_document_change.
9638    let Some(listener) = clone_listener(&DOCUMENT_CHANGE_LISTENER) else {
9639        return;
9640    };
9641    let Some(java_vm) = clone_java_vm() else {
9642        return;
9643    };
9644
9645    let mut env = match java_vm.attach_current_thread() {
9646        Ok(e) => e,
9647        Err(_) => return,
9648    };
9649
9650    let msg_jstr = match env.new_string(message) {
9651        Ok(s) => s,
9652        Err(_) => return,
9653    };
9654
9655    if let Err(e) = env.call_method(
9656        &listener,
9657        "onError",
9658        "(Ljava/lang/String;)V",
9659        &[JValue::Object(&msg_jstr)],
9660    ) {
9661        #[cfg(target_os = "android")]
9662        android_log(&format!(
9663            "dispatch_document_error: call_method failed: {:?}",
9664            e
9665        ));
9666        let _ = e;
9667        let _ = env.exception_describe();
9668        let _ = env.exception_clear();
9669    }
9670}
9671
9672// =============================================================================
9673// Outbound-frame poll API — dart:ffi / non-JNI consumers (ADR-059 Slice 1.b)
9674// =============================================================================
9675//
9676// Exposes the same BLE translator fan-out as `subscribeOutboundFramesJni` but
9677// via a queue-drain pattern instead of a foreign callback. The host calls
9678// `start_outbound_frames` once, then polls `poll_outbound_frames` at its own
9679// pace (e.g. from a Dart isolate loop), and calls `stop_outbound_frames` on
9680// teardown. Explicit stop avoids the Drop-drives-async problem that deferred
9681// the original `OutboundFrameCallback` UniFFI trait registration.
9682//
9683// The inbound direction (`ingest_inbound_frame`) accepts postcard-encoded
9684// typed BLE structs (i.e. the bytes *after* peat-btle has stripped the GATT
9685// framing and decrypted the envelope) and publishes the resulting document
9686// with `Some("ble")` origin so ADR-059 echo-suppression fires correctly.
9687
9688/// `OutboundSink` that appends encoded frames to an in-process queue instead
9689/// of dispatching to a JNI callback. Used by `start_outbound_frames`.
9690#[cfg(all(feature = "sync", feature = "bluetooth"))]
9691struct QueueOutboundSink {
9692    transport_id: &'static str,
9693    queue: Arc<std::sync::Mutex<std::collections::VecDeque<OutboundFrame>>>,
9694}
9695
9696#[cfg(all(feature = "sync", feature = "bluetooth"))]
9697#[async_trait::async_trait]
9698impl peat_mesh::transport::OutboundSink for QueueOutboundSink {
9699    async fn send_outbound(
9700        &self,
9701        bytes: Vec<u8>,
9702        ctx: &peat_mesh::transport::TranslationContext,
9703    ) -> anyhow::Result<()> {
9704        let collection = ctx.collection.clone().unwrap_or_default();
9705        self.queue
9706            .lock()
9707            .map_err(|e| anyhow::anyhow!("outbound_queue poisoned: {e}"))?
9708            .push_back(OutboundFrame {
9709                transport_id: self.transport_id.to_string(),
9710                collection,
9711                bytes,
9712            });
9713        Ok(())
9714    }
9715}
9716
9717/// Internal helper: registers the ble (and optionally ble-lite) translator +
9718/// sink pair with `TransportManager`, starts the fan-out, and returns the
9719/// `FanoutHandle`. On any failure, already-registered translators are rolled
9720/// back before the error propagates.
9721///
9722/// `sink_factory` is a closure that receives the `transport_id` string and
9723/// returns the `Arc<dyn OutboundSink>` to wire for that transport. Called
9724/// once for `"ble"` and, with `lite-bridge` on, once for `"ble-lite"`.
9725#[cfg(all(feature = "sync", feature = "bluetooth"))]
9726impl PeatNode {
9727    fn register_ble_fanout(
9728        &self,
9729        sink_factory: impl Fn(&'static str) -> Arc<dyn peat_mesh::transport::OutboundSink>,
9730    ) -> anyhow::Result<peat_mesh::transport::FanoutHandle> {
9731        let translator_dyn: Arc<dyn peat_mesh::transport::Translator> = self.ble_translator.clone();
9732        let ble_sink = sink_factory("ble");
9733
9734        let collections = vec![
9735            self.ble_translator.tracks_collection().to_string(),
9736            self.ble_translator.nodes_collection().to_string(),
9737            self.ble_translator.alerts_collection().to_string(),
9738            self.ble_translator.canned_messages_collection().to_string(),
9739        ];
9740
9741        #[cfg(feature = "lite-bridge")]
9742        let lite_bridge_translator_id = peat_mesh::transport::BLE_LITE_BRIDGE;
9743        #[cfg(feature = "lite-bridge")]
9744        let mut collections = collections;
9745        #[cfg(feature = "lite-bridge")]
9746        for c in LITE_BRIDGE_COLLECTIONS {
9747            // Dedup: `nodes` is already in the base list above. Pushing it again
9748            // would spawn a SECOND observer task for the same collection, and the
9749            // two race on the single-pop `pending_origins` map — one pops the
9750            // ble-lite origin (skips), the other pops `None` and re-fans the
9751            // ingested doc back out → the roster fan-out storm. One observer per
9752            // collection keeps ADR-059 echo-suppression intact.
9753            if !collections.iter().any(|existing| existing == c) {
9754                collections.push((*c).to_string());
9755            }
9756        }
9757        let collections = collections;
9758
9759        self.runtime.block_on(async {
9760            self.transport_manager
9761                .register_translator(
9762                    translator_dyn,
9763                    ble_sink,
9764                    peat_mesh::transport::TranslatorRegistrationConfig::ble(),
9765                )
9766                .await?;
9767
9768            #[cfg(feature = "lite-bridge")]
9769            {
9770                let lite_translator: Arc<dyn peat_mesh::transport::Translator> = Arc::new(
9771                    CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS),
9772                );
9773                let lite_sink = sink_factory(lite_bridge_translator_id);
9774                if let Err(e) = self
9775                    .transport_manager
9776                    .register_translator(
9777                        lite_translator,
9778                        lite_sink,
9779                        peat_mesh::transport::TranslatorRegistrationConfig::ble(),
9780                    )
9781                    .await
9782                {
9783                    let _ = self.transport_manager.unregister_translator("ble").await;
9784                    return Err(e);
9785                }
9786            }
9787
9788            match self
9789                .transport_manager
9790                .start_fanout(Arc::clone(&self.node), collections)
9791            {
9792                Ok(handle) => Ok(handle),
9793                Err(e) => {
9794                    #[cfg(feature = "lite-bridge")]
9795                    {
9796                        let _ = self
9797                            .transport_manager
9798                            .unregister_translator(lite_bridge_translator_id)
9799                            .await;
9800                    }
9801                    let _ = self.transport_manager.unregister_translator("ble").await;
9802                    Err(e)
9803                }
9804            }
9805        })
9806    }
9807
9808    /// Re-emit a freshly-ingested BLE frame onto the outbound queue so this
9809    /// node RELAYS it to its other BLE peers — multi-hop A->B->C.
9810    /// peat-mesh's fan-out already re-fans an ingested frame to OTHER
9811    /// transports (BLE->Wi-Fi/iroh) but suppresses same-transport BLE->BLE
9812    /// re-emit to avoid a broadcast loop; that suppression is exactly what
9813    /// strands an all-BLE follower. Re-emitting here closes that hop.
9814    /// Deduped by frame content with a short TTL so a relayed frame isn't
9815    /// re-broadcast in a loop: a NEW value (different bytes)
9816    /// relays immediately, while identical re-advertises inside the TTL window
9817    /// are dropped (this is what keeps it from recreating the storm the
9818    /// suppression was guarding against). No-op unless an outbound subscription
9819    /// is actively draining the queue, so an idle node doesn't grow it.
9820    fn relay_ble_frame(&self, transport_id: &str, collection: &str, bytes: &[u8]) {
9821        use std::collections::hash_map::DefaultHasher;
9822        use std::hash::{Hash, Hasher};
9823        use std::time::{Duration, Instant};
9824
9825        const RELAY_DEDUP_TTL: Duration = Duration::from_secs(5);
9826        const RELAY_SEEN_CAP: usize = 2048;
9827
9828        // Do NOT relay presence ("nodes"): its heartbeat timestamp changes every
9829        // beat, so every frame is unique and escapes the content dedup — relaying
9830        // it ~Nx-amplifies BLE traffic (congestion → missed heartbeats → roster
9831        // liveness flapping) AND re-broadcasts stale node-identity docs from
9832        // peers' stores (resurfacing zombie identities, so the roster flips
9833        // between a node's old id and its callsign). Presence reaches direct
9834        // neighbours via each node's own advertise; only app STATE needs
9835        // multi-hop relay (counter "demo", "cells", "mission", "commands",
9836        // "markers"), and those re-advertise identical bytes so the dedup
9837        // throttles them to one relay per change.
9838        if collection == "nodes" {
9839            return;
9840        }
9841
9842        let active = match self.outbound_fanout.lock() {
9843            Ok(g) => g.is_some(),
9844            Err(e) => e.into_inner().is_some(),
9845        };
9846        if !active {
9847            return;
9848        }
9849
9850        let mut h = DefaultHasher::new();
9851        transport_id.hash(&mut h);
9852        collection.hash(&mut h);
9853        bytes.hash(&mut h);
9854        let key = h.finish();
9855
9856        let now = Instant::now();
9857        {
9858            let mut seen = self.relay_seen.lock().unwrap_or_else(|e| e.into_inner());
9859            seen.retain(|_, t| now.duration_since(*t) < RELAY_DEDUP_TTL);
9860            if seen.contains_key(&key) {
9861                return; // identical frame relayed recently — drop to break
9862                        // loops
9863            }
9864            if seen.len() >= RELAY_SEEN_CAP {
9865                seen.clear();
9866            }
9867            seen.insert(key, now);
9868        }
9869
9870        self.outbound_queue
9871            .lock()
9872            .unwrap_or_else(|e| e.into_inner())
9873            .push_back(OutboundFrame {
9874                transport_id: transport_id.to_string(),
9875                collection: collection.to_string(),
9876                bytes: bytes.to_vec(),
9877            });
9878    }
9879}
9880
9881#[cfg(all(feature = "sync", feature = "bluetooth"))]
9882#[uniffi::export]
9883impl PeatNode {
9884    /// Subscribe to outbound BLE frames via a poll queue.
9885    ///
9886    /// After calling this, encoded frames produced by the `BleTranslator`
9887    /// fan-out accumulate in an internal unbounded queue. Call
9888    /// [`poll_outbound_frames`] frequently to drain it — if the consumer
9889    /// pauses polling the queue will grow without bound, one `Vec<u8>`
9890    /// payload per BLE frame.
9891    ///
9892    /// Idempotent — a second call while already subscribed is a no-op
9893    /// (returns `Ok`).
9894    ///
9895    /// Call [`stop_outbound_frames`] to unsubscribe, tear down the fan-out,
9896    /// and clear any residual frames from the queue.
9897    pub fn start_outbound_frames(&self) -> Result<(), PeatError> {
9898        {
9899            let guard = self
9900                .outbound_fanout
9901                .lock()
9902                .map_err(|_| PeatError::SyncError {
9903                    msg: "outbound_fanout poisoned".to_string(),
9904                })?;
9905            if guard.is_some() {
9906                return Ok(()); // already running
9907            }
9908        }
9909        let queue = Arc::clone(&self.outbound_queue);
9910        let handle = self
9911            .register_ble_fanout(move |tid| {
9912                Arc::new(QueueOutboundSink {
9913                    transport_id: tid,
9914                    queue: Arc::clone(&queue),
9915                })
9916            })
9917            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
9918        *self
9919            .outbound_fanout
9920            .lock()
9921            .map_err(|_| PeatError::SyncError {
9922                msg: "outbound_fanout poisoned".to_string(),
9923            })? = Some(handle);
9924        Ok(())
9925    }
9926
9927    /// Drain all queued outbound frames produced since the last call.
9928    ///
9929    /// Returns an empty `Vec` when no frames are pending or when
9930    /// [`start_outbound_frames`] has not been called. Non-blocking.
9931    pub fn poll_outbound_frames(&self) -> Vec<OutboundFrame> {
9932        // If the Mutex is poisoned (a thread panicked while holding it) we
9933        // recover the inner value rather than propagating a panic — the
9934        // VecDeque state is consistent enough to drain safely.
9935        let mut q = self
9936            .outbound_queue
9937            .lock()
9938            .unwrap_or_else(|e| e.into_inner());
9939        q.drain(..).collect()
9940    }
9941
9942    /// Stop outbound-frame delivery and tear down the BLE fan-out.
9943    ///
9944    /// Drops the `FanoutHandle` (cancels observer tasks), unregisters the BLE
9945    /// translator(s), and clears the outbound queue so that stale frames are
9946    /// not delivered after a subsequent [`start_outbound_frames`].
9947    ///
9948    /// Idempotent — safe to call when not subscribed.
9949    pub fn stop_outbound_frames(&self) {
9950        let handle = self
9951            .outbound_fanout
9952            .lock()
9953            .unwrap_or_else(|e| e.into_inner())
9954            .take();
9955        drop(handle); // cancels fan-out observer tasks
9956
9957        // Clear residual frames so a subsequent start_outbound_frames sees a
9958        // clean queue rather than frames from the previous subscription window.
9959        self.outbound_queue
9960            .lock()
9961            .unwrap_or_else(|e| e.into_inner())
9962            .clear();
9963
9964        // Unregister the translator(s) so a future start_outbound_frames
9965        // can re-register without hitting the duplicate-id rejection.
9966        self.runtime.block_on(async {
9967            #[cfg(feature = "lite-bridge")]
9968            {
9969                let _ = self
9970                    .transport_manager
9971                    .unregister_translator(peat_mesh::transport::BLE_LITE_BRIDGE)
9972                    .await;
9973            }
9974            let _ = self.transport_manager.unregister_translator("ble").await;
9975        });
9976    }
9977
9978    /// Feed a BLE inbound frame into the mesh.
9979    ///
9980    /// `postcard_bytes` must be the postcard-encoded typed BLE struct
9981    /// produced by `peat-btle` *after* it has stripped the GATT framing and
9982    /// decrypted the envelope (i.e. the bytes `peat-btle` would pass to its
9983    /// internal `Translator::decode_inbound`).
9984    ///
9985    /// `collection` must name the document collection the bytes belong to
9986    /// (e.g. `"tracks"`, `"platforms"`) — peat-btle knows this from the GATT
9987    /// characteristic or frame type and should pass it through unchanged.
9988    ///
9989    /// On success returns the newly-published document ID. Returns `Ok(None)`
9990    /// if the bytes are addressed to an unknown collection (graceful decline).
9991    pub fn ingest_inbound_frame(
9992        &self,
9993        collection: String,
9994        postcard_bytes: Vec<u8>,
9995    ) -> Result<Option<String>, PeatError> {
9996        use peat_mesh::transport::{TranslationContext, Translator};
9997        let ctx = TranslationContext::inbound("ble").with_collection(collection);
9998        let doc = self
9999            .runtime
10000            .block_on(self.ble_translator.decode_inbound(&postcard_bytes, &ctx))
10001            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
10002        let Some(mesh_doc) = doc else {
10003            return Ok(None);
10004        };
10005        let collection_name = ctx.collection.unwrap_or_default();
10006        let id = self
10007            .runtime
10008            .block_on(self.node.publish_with_origin(
10009                &collection_name,
10010                mesh_doc,
10011                Some("ble".to_string()),
10012            ))
10013            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
10014        // Multi-hop: relay this frame to our other BLE peers (deduped).
10015        self.relay_ble_frame("ble", &collection_name, &postcard_bytes);
10016        Ok(Some(id.to_string()))
10017    }
10018
10019    /// Publish a JSON document through the **node layer** — the same path the
10020    /// Android `publishDocumentJni` uses — so the write reaches the ADR-059
10021    /// fan-out and is emitted over the bridged transports (BLE/Wi-Fi). The
10022    /// `id` field in the JSON, when present, becomes the document id
10023    /// (returned).
10024    ///
10025    /// Use this instead of `put_document` when the write must propagate to
10026    /// peers via the bridged radios: `put_document`/`put_node` write straight
10027    /// to `storage_backend`, which the fan-out does not observe, so those never
10028    /// emit a BLE frame. Needed by the iOS bridge (which drives the poll API
10029    /// from Dart and has no JNI `publishDocumentJni`).
10030    #[cfg(feature = "sync")]
10031    pub fn publish_document(&self, collection: String, json: String) -> Result<String, PeatError> {
10032        self.runtime
10033            .block_on(publish_document_into_node(&self.node, &collection, &json))
10034            .map_err(|e| PeatError::SyncError { msg: e.to_string() })
10035    }
10036}
10037
10038// `ingest_inbound_lite_frame` lives in its OWN cfg-gated `#[uniffi::export]`
10039// block (not the `all(sync, bluetooth)` block above) so that under
10040// `sync,bluetooth` WITHOUT `lite-bridge` the whole export — including the
10041// generated scaffolding's call to the method — is stripped before the macro
10042// runs. With a per-method `#[cfg(lite-bridge)]` inside the broader block, the
10043// export macro still emitted a call to the cfg'd-out method (E0599). See peat#986.
10044#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10045#[uniffi::export]
10046impl PeatNode {
10047    /// Ingest an inbound BLE frame that arrived on the universal-Document
10048    /// (peat-lite / `ble-lite`) codec, as opposed to the typed 0xB6 path in
10049    /// [`ingest_inbound_frame`]. Decodes via the `CollectionGatedLiteBridge`
10050    /// and republishes with `Some("ble-lite")` origin so the mesh re-fans it
10051    /// to the other transports without looping back to BLE. Used for raw
10052    /// collections (e.g. the `demo` counter) that the typed translator
10053    /// declines.
10054    pub fn ingest_inbound_lite_frame(
10055        &self,
10056        collection: String,
10057        envelope_bytes: Vec<u8>,
10058    ) -> Result<Option<String>, PeatError> {
10059        use peat_mesh::transport::{TranslationContext, Translator, BLE_LITE_BRIDGE};
10060        let bridge = CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS);
10061        let ctx = TranslationContext::inbound(BLE_LITE_BRIDGE).with_collection(collection);
10062        let doc = self
10063            .runtime
10064            .block_on(bridge.decode_inbound(&envelope_bytes, &ctx))
10065            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
10066        let Some(mesh_doc) = doc else {
10067            return Ok(None);
10068        };
10069        let collection_name = ctx.collection.unwrap_or_default();
10070        let id = self
10071            .runtime
10072            .block_on(self.node.publish_with_origin(
10073                &collection_name,
10074                mesh_doc,
10075                Some(BLE_LITE_BRIDGE.to_string()),
10076            ))
10077            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
10078        // Multi-hop: relay this lite frame to our other BLE peers (deduped).
10079        self.relay_ble_frame(BLE_LITE_BRIDGE, &collection_name, &envelope_bytes);
10080        Ok(Some(id.to_string()))
10081    }
10082}
10083
10084// =============================================================================
10085// OutboundFrameCallback JNI (ADR-059 Slice 1.b)
10086// =============================================================================
10087//
10088// Bridges `TransportManager`'s per-transport fan-out (peat-mesh) into a
10089// Kotlin callback so a consumer plugin's BLE manager can deliver encoded
10090// frames over the radio. The JNI shape mirrors `subscribeDocumentChangesJni`
10091// — a single GlobalRef in a static slot, replaceable on re-subscribe — so
10092// the same patterns audited on PR #803 carry over.
10093
10094/// `OutboundSink` implementation that forwards encoded bytes into the
10095/// registered Kotlin listener. One instance is registered with
10096/// `TransportManager` per `transport_id` we want to fan out — currently
10097/// `"ble"` for typed 0xB6 frames and (with `lite-bridge` on) `"ble-lite"`
10098/// for universal Document envelopes. The structure generalizes to
10099/// LoRa/SBD/etc.
10100#[cfg(all(feature = "sync", feature = "bluetooth"))]
10101struct JniOutboundSink {
10102    transport_id: &'static str,
10103}
10104
10105/// `Translator` wrapper that gates `encode_outbound` by collection.
10106/// Wraps a [`peat_mesh::transport::LiteBridgeTranslator`] (catch-all
10107/// codec — encodes any collection it's handed) with a peat-ffi-policy
10108/// allow-list, so the universal-Document fan-out only fires for
10109/// collections explicitly opted in.
10110///
10111/// Without this wrapper, registering both the typed `BleTranslator`
10112/// (which encodes `"tracks"`/`"nodes"`/`"alerts"`/`"canned_messages"`
10113/// to compact 0xB6 frames) AND the catch-all `LiteBridgeTranslator` on
10114/// the same `TransportManager` would cause **double emission** for the
10115/// typed collections — both translators would encode the same doc and
10116/// dispatch separate frames to Kotlin. The plugin would receive
10117/// duplicate copies, and BLE-link bandwidth doubles for no gain. The
10118/// gate stays in peat-ffi (the consumer that owns the policy decision)
10119/// rather than in `LiteBridgeTranslator` itself, matching ADR-059's
10120/// "policy lives at the consumer, codec is generic" direction.
10121///
10122/// Slice 2's per-doc `allowed_transports` will eventually replace this
10123/// with a runtime annotation on each Document; until then, the
10124/// peat-ffi-static allow-list is the right shape.
10125#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10126struct CollectionGatedLiteBridge {
10127    inner: peat_mesh::transport::LiteBridgeTranslator,
10128    allowed: std::collections::HashSet<&'static str>,
10129}
10130
10131#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10132impl CollectionGatedLiteBridge {
10133    fn for_ble_with_collections(collections: &'static [&'static str]) -> Self {
10134        Self {
10135            inner: peat_mesh::transport::LiteBridgeTranslator::for_ble(),
10136            allowed: collections.iter().copied().collect(),
10137        }
10138    }
10139}
10140
10141#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10142#[async_trait::async_trait]
10143impl peat_mesh::transport::Translator for CollectionGatedLiteBridge {
10144    fn transport_id(&self) -> &'static str {
10145        self.inner.transport_id()
10146    }
10147
10148    async fn encode_outbound(
10149        &self,
10150        doc: &peat_mesh::sync::types::Document,
10151        ctx: &peat_mesh::transport::TranslationContext,
10152    ) -> Option<Vec<u8>> {
10153        // Decline silently for collections outside the allow-list.
10154        // This is the policy filter, not a codec error — matches the
10155        // BleTranslator decline behaviour for unknown collections.
10156        let collection = ctx.collection.as_deref()?;
10157        if !self.allowed.contains(collection) {
10158            return None;
10159        }
10160        self.inner.encode_outbound(doc, ctx).await
10161    }
10162
10163    async fn decode_inbound(
10164        &self,
10165        bytes: &[u8],
10166        ctx: &peat_mesh::transport::TranslationContext,
10167    ) -> anyhow::Result<Option<peat_mesh::sync::types::Document>> {
10168        // Inbound is collection-agnostic at this codec level (the
10169        // envelope carries the collection). The receive-side policy
10170        // decision (which collections to publish_with_origin) lives
10171        // in the consumer (plugin Kotlin), so the gate doesn't apply
10172        // here.
10173        self.inner.decode_inbound(bytes, ctx).await
10174    }
10175}
10176
10177/// Universal-Document collections that ride the `"ble-lite"` codec
10178/// instead of the typed 0xB6 path. Add new entries here when a new
10179/// collection joins the universal transport (chats, alerts-v2, etc.).
10180/// Keep the list tight — every entry is one more codec the universal
10181/// path encodes for, and double-emission with the typed BleTranslator
10182/// would result if both lists overlap.
10183// `nodes` (capabilities/roster) rides the universal codec — the typed
10184// BleTranslator declines it (it only encodes tracks/platforms/alerts/
10185// canned_messages), so without this entry capabilities never reach a BLE
10186// frame and remote rosters stay empty. Safe to carry here now that
10187// `put_node` publishes through the node layer (same wrapped representation
10188// as the ingest), so the two sides converge instead of re-syncing forever.
10189#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10190const LITE_BRIDGE_COLLECTIONS: &[&str] =
10191    &["markers", "demo", "nodes", "mission", "cells", "commands"];
10192
10193#[cfg(all(feature = "sync", feature = "bluetooth"))]
10194#[async_trait::async_trait]
10195impl peat_mesh::transport::OutboundSink for JniOutboundSink {
10196    async fn send_outbound(
10197        &self,
10198        bytes: Vec<u8>,
10199        ctx: &peat_mesh::transport::TranslationContext,
10200    ) -> anyhow::Result<()> {
10201        let collection = ctx.collection.as_deref().unwrap_or("");
10202        dispatch_outbound_frame(self.transport_id, collection, &bytes);
10203        Ok(())
10204    }
10205}
10206
10207/// JNI: Subscribe to outbound BLE-encoded frames produced by the
10208/// `BleTranslator` in `TransportManager`'s fan-out.
10209///
10210/// Kotlin signature:
10211/// `external fun subscribeOutboundFramesJni(handle: Long, listener:
10212/// OutboundFrameListener): Boolean`
10213///
10214/// The listener receives `onFrame(transportId, collection, bytes)` for
10215/// each encoded document the translator produces. Calls fire on the
10216/// tokio runtime thread; the listener must tolerate any-thread invocation
10217/// (the plugin posts to its own executor before touching radio state).
10218///
10219/// **Idempotent**: a second call replaces the listener `GlobalRef`; the
10220/// underlying translator + sink registration and observer fan-out tasks
10221/// are kept alive across the swap so no frames are lost between the two
10222/// listeners. Use `unsubscribeOutboundFramesJni` to fully tear down.
10223#[cfg(all(feature = "sync", feature = "bluetooth"))]
10224#[no_mangle]
10225pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_subscribeOutboundFramesJni(
10226    mut env: JNIEnv,
10227    _class: JClass,
10228    handle: i64,
10229    listener: jni::objects::JObject,
10230) -> jboolean {
10231    if handle == 0 {
10232        #[cfg(target_os = "android")]
10233        android_log("subscribeOutboundFramesJni: Invalid handle (0)");
10234        return 0;
10235    }
10236
10237    let listener_global = match env.new_global_ref(&listener) {
10238        Ok(g) => g,
10239        Err(e) => {
10240            #[cfg(target_os = "android")]
10241            android_log(&format!(
10242                "subscribeOutboundFramesJni: new_global_ref failed: {:?}",
10243                e
10244            ));
10245            let _ = e;
10246            return 0;
10247        }
10248    };
10249
10250    // Listener swap is unconditional — second-subscribe just rebinds.
10251    *OUTBOUND_FRAME_LISTENER.lock().unwrap() = Some(listener_global);
10252
10253    // If a fan-out is already running, the swap above is sufficient — the
10254    // existing JniOutboundSink reads the listener slot dynamically.
10255    {
10256        let handle_slot = OUTBOUND_FRAME_FANOUT.lock().unwrap();
10257        if handle_slot.is_some() {
10258            return 1;
10259        }
10260    }
10261
10262    // First subscribe: register translator + sink and start fan-out.
10263    // `TransportManager` is not Clone, so we hold the `node_owner` Arc by
10264    // borrow (not by taking ownership) for the duration of the call;
10265    // forget happens after the registration block completes.
10266    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
10267
10268    // Delegate to the shared registration helper so the JNI and the
10269    // poll-API paths stay aligned. The factory produces a `JniOutboundSink`
10270    // whose `send_outbound` dispatches to the registered Kotlin GlobalRef.
10271    let final_result =
10272        node_owner.register_ble_fanout(|tid| Arc::new(JniOutboundSink { transport_id: tid }));
10273
10274    std::mem::forget(node_owner);
10275
10276    match final_result {
10277        Ok(fanout_handle) => {
10278            *OUTBOUND_FRAME_FANOUT.lock().unwrap() = Some(fanout_handle);
10279            1
10280        }
10281        Err(_e) => {
10282            // Roll back the listener stash so a future retry isn't observed
10283            // as "already subscribed".
10284            *OUTBOUND_FRAME_LISTENER.lock().unwrap() = None;
10285            #[cfg(target_os = "android")]
10286            android_log(&format!(
10287                "subscribeOutboundFramesJni: register/start_fanout failed: {}",
10288                _e
10289            ));
10290            0
10291        }
10292    }
10293}
10294
10295/// JNI: Unsubscribe from outbound frame delivery.
10296///
10297/// Kotlin signature: `external fun unsubscribeOutboundFramesJni(handle: Long)`
10298///
10299/// Drops the `FanoutHandle` (cancelling observer tasks), unregisters the
10300/// translator, and clears the listener `GlobalRef`. Idempotent — calling
10301/// twice or before any subscribe is a no-op.
10302#[cfg(all(feature = "sync", feature = "bluetooth"))]
10303#[no_mangle]
10304pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_unsubscribeOutboundFramesJni(
10305    _env: JNIEnv,
10306    _class: JClass,
10307    handle: i64,
10308) {
10309    // Drop the FanoutHandle first so no further frames are fanned out
10310    // while we're tearing down.
10311    let _ = OUTBOUND_FRAME_FANOUT.lock().unwrap().take();
10312
10313    if handle != 0 {
10314        let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
10315        node_owner.runtime.block_on(async {
10316            // Unregister both translators that the lite-bridge build
10317            // registered (ble + ble-lite). Each call independently
10318            // rejects "translator not registered", so the order doesn't
10319            // matter and a missing entry on either side is benign.
10320            #[cfg(feature = "lite-bridge")]
10321            {
10322                let _ = node_owner
10323                    .transport_manager
10324                    .unregister_translator(peat_mesh::transport::BLE_LITE_BRIDGE)
10325                    .await;
10326            }
10327            let _ = node_owner
10328                .transport_manager
10329                .unregister_translator("ble")
10330                .await;
10331        });
10332        std::mem::forget(node_owner);
10333    }
10334
10335    *OUTBOUND_FRAME_LISTENER.lock().unwrap() = None;
10336
10337    #[cfg(target_os = "android")]
10338    android_log("unsubscribeOutboundFramesJni: subscription torn down");
10339}
10340
10341/// Dispatch an outbound frame to the registered Kotlin listener.
10342/// Attaches the current tokio worker thread to the JVM if needed.
10343#[cfg(all(feature = "sync", feature = "bluetooth"))]
10344fn dispatch_outbound_frame(transport_id: &str, collection: &str, bytes: &[u8]) {
10345    // Snapshot then drop locks before JNI work — see dispatch_document_change.
10346    let Some(listener) = clone_listener(&OUTBOUND_FRAME_LISTENER) else {
10347        return;
10348    };
10349    let Some(java_vm) = clone_java_vm() else {
10350        return;
10351    };
10352
10353    let mut env = match java_vm.attach_current_thread() {
10354        Ok(e) => e,
10355        Err(e) => {
10356            #[cfg(target_os = "android")]
10357            android_log(&format!("dispatch_outbound_frame: attach failed: {:?}", e));
10358            let _ = e;
10359            return;
10360        }
10361    };
10362
10363    let transport_jstr = match env.new_string(transport_id) {
10364        Ok(s) => s,
10365        Err(_) => return,
10366    };
10367    let collection_jstr = match env.new_string(collection) {
10368        Ok(s) => s,
10369        Err(_) => return,
10370    };
10371    let bytes_jarr = match env.byte_array_from_slice(bytes) {
10372        Ok(a) => a,
10373        Err(e) => {
10374            #[cfg(target_os = "android")]
10375            android_log(&format!(
10376                "dispatch_outbound_frame: byte_array_from_slice failed: {:?}",
10377                e
10378            ));
10379            let _ = e;
10380            return;
10381        }
10382    };
10383
10384    if let Err(e) = env.call_method(
10385        &listener,
10386        "onFrame",
10387        "(Ljava/lang/String;Ljava/lang/String;[B)V",
10388        &[
10389            JValue::Object(&transport_jstr),
10390            JValue::Object(&collection_jstr),
10391            JValue::Object(&bytes_jarr),
10392        ],
10393    ) {
10394        #[cfg(target_os = "android")]
10395        android_log(&format!(
10396            "dispatch_outbound_frame: call_method failed: {:?}",
10397            e
10398        ));
10399        let _ = e;
10400        let _ = env.exception_describe();
10401        let _ = env.exception_clear();
10402    }
10403}
10404
10405// =============================================================================
10406// Blob Transfer JNI (ADR-060)
10407// =============================================================================
10408
10409/// JNI: Enable blob transfer on the PeatNode.
10410///
10411/// Kotlin signature:
10412/// `external fun enableBlobTransferJni(handle: Long, bindAddr: String?):
10413/// Boolean`
10414#[cfg(feature = "sync")]
10415#[no_mangle]
10416pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_enableBlobTransferJni(
10417    mut env: JNIEnv,
10418    _class: JClass,
10419    handle: i64,
10420    bind_addr: JString,
10421) -> jboolean {
10422    if handle == 0 {
10423        return 0;
10424    }
10425    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10426
10427    let addr_str: Option<String> = if bind_addr.is_null() {
10428        None
10429    } else {
10430        env.get_string(&bind_addr).ok().map(|s| s.into())
10431    };
10432    let bind: Option<std::net::SocketAddr> =
10433        addr_str.and_then(|s| if s.is_empty() { None } else { s.parse().ok() });
10434
10435    let result = match node.enable_blob_transfer(bind) {
10436        Ok(()) => 1,
10437        Err(e) => {
10438            #[cfg(target_os = "android")]
10439            android_log(&format!("enableBlobTransferJni: {}", e));
10440            0
10441        }
10442    };
10443    std::mem::forget(node);
10444    result
10445}
10446
10447/// JNI: Add a known blob peer.
10448///
10449/// Kotlin signature:
10450/// `external fun blobAddPeerJni(handle: Long, peerIdHex: String, address:
10451/// String): Boolean`
10452#[cfg(feature = "sync")]
10453#[no_mangle]
10454pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobAddPeerJni(
10455    mut env: JNIEnv,
10456    _class: JClass,
10457    handle: i64,
10458    peer_id_hex: JString,
10459    address: JString,
10460) -> jboolean {
10461    if handle == 0 {
10462        return 0;
10463    }
10464    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10465
10466    let peer_hex: String = match env.get_string(&peer_id_hex) {
10467        Ok(s) => s.into(),
10468        Err(_) => {
10469            std::mem::forget(node);
10470            return 0;
10471        }
10472    };
10473    let addr: String = match env.get_string(&address) {
10474        Ok(s) => s.into(),
10475        Err(_) => {
10476            std::mem::forget(node);
10477            return 0;
10478        }
10479    };
10480
10481    let result = match node.blob_add_peer(&peer_hex, &addr) {
10482        Ok(()) => 1,
10483        Err(e) => {
10484            #[cfg(target_os = "android")]
10485            android_log(&format!("blobAddPeerJni: {}", e));
10486            0
10487        }
10488    };
10489    std::mem::forget(node);
10490    result
10491}
10492
10493/// JNI: Store bytes as a blob. Returns the content hash as a hex string.
10494///
10495/// Kotlin signature:
10496/// `external fun blobPutJni(handle: Long, data: ByteArray, contentType:
10497/// String): String?`
10498#[cfg(feature = "sync")]
10499#[no_mangle]
10500pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobPutJni(
10501    mut env: JNIEnv,
10502    _class: JClass,
10503    handle: i64,
10504    data: jni::objects::JByteArray,
10505    content_type: JString,
10506) -> jstring {
10507    if handle == 0 {
10508        return std::ptr::null_mut();
10509    }
10510    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10511
10512    let bytes = match env.convert_byte_array(&data) {
10513        Ok(b) => b,
10514        Err(_) => {
10515            std::mem::forget(node);
10516            return std::ptr::null_mut();
10517        }
10518    };
10519    let ct: String = match env.get_string(&content_type) {
10520        Ok(s) => s.into(),
10521        Err(_) => {
10522            std::mem::forget(node);
10523            return std::ptr::null_mut();
10524        }
10525    };
10526
10527    let result = match node.blob_put(&bytes, &ct) {
10528        Ok(hash) => env.new_string(&hash).ok().map(|s| s.into_raw()),
10529        Err(e) => {
10530            #[cfg(target_os = "android")]
10531            android_log(&format!("blobPutJni: {}", e));
10532            None
10533        }
10534    };
10535    std::mem::forget(node);
10536    result.unwrap_or(std::ptr::null_mut())
10537}
10538
10539/// JNI: Fetch blob bytes by hash. Returns byte[] or null.
10540///
10541/// Kotlin signature:
10542/// `external fun blobGetJni(handle: Long, hashHex: String): ByteArray?`
10543#[cfg(feature = "sync")]
10544#[no_mangle]
10545pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobGetJni(
10546    mut env: JNIEnv,
10547    _class: JClass,
10548    handle: i64,
10549    hash_hex: JString,
10550) -> jni::objects::JByteArray<'static> {
10551    if handle == 0 {
10552        return JByteArray::default();
10553    }
10554    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10555
10556    let hash: String = match env.get_string(&hash_hex) {
10557        Ok(s) => s.into(),
10558        Err(_) => {
10559            std::mem::forget(node);
10560            return JByteArray::default();
10561        }
10562    };
10563
10564    let result = match node.blob_get(&hash) {
10565        Ok(bytes) => env.byte_array_from_slice(&bytes).ok(),
10566        Err(e) => {
10567            #[cfg(target_os = "android")]
10568            android_log(&format!("blobGetJni: {}", e));
10569            None
10570        }
10571    };
10572    std::mem::forget(node);
10573    // Safety: JByteArray has no lifetime on the default — transmute is needed
10574    // because the JNI return type doesn't carry a lifetime parameter.
10575    result
10576        .map(|arr| unsafe { std::mem::transmute(arr) })
10577        .unwrap_or(JByteArray::default())
10578}
10579
10580/// JNI: Check if blob exists locally.
10581///
10582/// Kotlin signature:
10583/// `external fun blobExistsLocallyJni(handle: Long, hashHex: String): Boolean`
10584#[cfg(feature = "sync")]
10585#[no_mangle]
10586pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobExistsLocallyJni(
10587    mut env: JNIEnv,
10588    _class: JClass,
10589    handle: i64,
10590    hash_hex: JString,
10591) -> jboolean {
10592    if handle == 0 {
10593        return 0;
10594    }
10595    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10596
10597    let hash: String = match env.get_string(&hash_hex) {
10598        Ok(s) => s.into(),
10599        Err(_) => {
10600            std::mem::forget(node);
10601            return 0;
10602        }
10603    };
10604
10605    let result = if node.blob_exists_locally(&hash) {
10606        1
10607    } else {
10608        0
10609    };
10610    std::mem::forget(node);
10611    result
10612}
10613
10614/// JNI: Get blob endpoint ID as hex string (or null if blob transfer disabled).
10615///
10616/// Kotlin signature:
10617/// `external fun blobEndpointIdJni(handle: Long): String?`
10618#[cfg(feature = "sync")]
10619#[no_mangle]
10620pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobEndpointIdJni(
10621    mut env: JNIEnv,
10622    _class: JClass,
10623    handle: i64,
10624) -> jstring {
10625    if handle == 0 {
10626        return std::ptr::null_mut();
10627    }
10628    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10629
10630    let result = match node.blob_endpoint_id() {
10631        Some(id) => env.new_string(&id).ok().map(|s| s.into_raw()),
10632        None => None,
10633    };
10634    std::mem::forget(node);
10635    result.unwrap_or(std::ptr::null_mut())
10636}
10637
10638// =============================================================================
10639// JNI Native Method Registration
10640// =============================================================================
10641//
10642// Android's linker namespace isolation prevents normal JNI symbol lookup.
10643// We provide a nativeInit function that Kotlin must call after System.load()
10644// to explicitly register the native methods.
10645
10646/// Register native methods for PeatJni class
10647///
10648/// This must be called from Kotlin after System.load() to register native
10649/// methods. Android's classloader isolation prevents JNI_OnLoad from finding
10650/// the class.
10651///
10652/// Kotlin usage:
10653/// ```kotlin
10654/// companion object {
10655///     init {
10656///         System.load(libPath)
10657///         nativeInit()
10658///     }
10659///     @JvmStatic external fun nativeInit()
10660/// }
10661/// ```
10662#[no_mangle]
10663pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_nativeInit(
10664    mut env: JNIEnv,
10665    class: JClass,
10666) {
10667    use jni::NativeMethod;
10668
10669    let methods: Vec<NativeMethod> = vec![
10670        NativeMethod {
10671            name: "peatVersion".into(),
10672            sig: "()Ljava/lang/String;".into(),
10673            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peatVersion as *mut c_void,
10674        },
10675        NativeMethod {
10676            name: "testJni".into(),
10677            sig: "()Ljava/lang/String;".into(),
10678            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_testJni as *mut c_void,
10679        },
10680        #[cfg(target_os = "android")]
10681        NativeMethod {
10682            name: "setAndroidContextJni".into(),
10683            // (Ljava/lang/Object;)V — Kotlin `Any` lowers to java.lang.Object.
10684            sig: "(Ljava/lang/Object;)V".into(),
10685            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_setAndroidContextJni as *mut c_void,
10686        },
10687        #[cfg(target_os = "android")]
10688        NativeMethod {
10689            name: "verifyAndroidContextJni".into(),
10690            sig: "()Z".into(),
10691            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_verifyAndroidContextJni as *mut c_void,
10692        },
10693        #[cfg(feature = "sync")]
10694        NativeMethod {
10695            name: "createNodeJni".into(),
10696            sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)J".into(),
10697            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeJni as *mut c_void,
10698        },
10699        #[cfg(feature = "sync")]
10700        NativeMethod {
10701            name: "getGlobalNodeHandleJni".into(),
10702            sig: "()J".into(),
10703            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getGlobalNodeHandleJni as *mut c_void,
10704        },
10705        #[cfg(feature = "sync")]
10706        NativeMethod {
10707            name: "clearGlobalNodeHandleJni".into(),
10708            sig: "()V".into(),
10709            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_clearGlobalNodeHandleJni as *mut c_void,
10710        },
10711        #[cfg(feature = "sync")]
10712        NativeMethod {
10713            name: "nodeIdJni".into(),
10714            sig: "(J)Ljava/lang/String;".into(),
10715            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_nodeIdJni as *mut c_void,
10716        },
10717        #[cfg(feature = "sync")]
10718        NativeMethod {
10719            name: "peerCountJni".into(),
10720            sig: "(J)I".into(),
10721            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peerCountJni as *mut c_void,
10722        },
10723        #[cfg(feature = "sync")]
10724        NativeMethod {
10725            name: "connectedPeersJni".into(),
10726            sig: "(J)Ljava/lang/String;".into(),
10727            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectedPeersJni as *mut c_void,
10728        },
10729        #[cfg(feature = "sync")]
10730        NativeMethod {
10731            name: "requestSyncJni".into(),
10732            sig: "(J)Z".into(),
10733            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_requestSyncJni as *mut c_void,
10734        },
10735        #[cfg(feature = "sync")]
10736        NativeMethod {
10737            name: "endpointSocketAddrJni".into(),
10738            sig: "(J)Ljava/lang/String;".into(),
10739            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_endpointSocketAddrJni as *mut c_void,
10740        },
10741        #[cfg(feature = "sync")]
10742        NativeMethod {
10743            name: "getDocumentJni".into(),
10744            sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
10745            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getDocumentJni as *mut c_void,
10746        },
10747        #[cfg(feature = "sync")]
10748        NativeMethod {
10749            name: "forceStoreErrorForTestingJni".into(),
10750            sig: "(J)Z".into(),
10751            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_forceStoreErrorForTestingJni
10752                as *mut c_void,
10753        },
10754        #[cfg(feature = "sync")]
10755        NativeMethod {
10756            name: "startSyncJni".into(),
10757            sig: "(J)Z".into(),
10758            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_startSyncJni as *mut c_void,
10759        },
10760        #[cfg(feature = "sync")]
10761        NativeMethod {
10762            name: "freeNodeJni".into(),
10763            sig: "(J)V".into(),
10764            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_freeNodeJni as *mut c_void,
10765        },
10766        #[cfg(feature = "sync")]
10767        NativeMethod {
10768            name: "getCellsJni".into(),
10769            sig: "(J)Ljava/lang/String;".into(),
10770            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCellsJni as *mut c_void,
10771        },
10772        #[cfg(feature = "sync")]
10773        NativeMethod {
10774            name: "getTracksJni".into(),
10775            sig: "(J)Ljava/lang/String;".into(),
10776            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getTracksJni as *mut c_void,
10777        },
10778        #[cfg(feature = "sync")]
10779        NativeMethod {
10780            name: "getNodesJni".into(),
10781            sig: "(J)Ljava/lang/String;".into(),
10782            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getNodesJni as *mut c_void,
10783        },
10784        #[cfg(feature = "sync")]
10785        NativeMethod {
10786            name: "getCommandsJni".into(),
10787            sig: "(J)Ljava/lang/String;".into(),
10788            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCommandsJni as *mut c_void,
10789        },
10790        #[cfg(feature = "sync")]
10791        NativeMethod {
10792            name: "publishNodeJni".into(),
10793            sig: "(JLjava/lang/String;)Z".into(),
10794            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishNodeJni as *mut c_void,
10795        },
10796        #[cfg(feature = "sync")]
10797        NativeMethod {
10798            name: "getMarkersJni".into(),
10799            sig: "(J)Ljava/lang/String;".into(),
10800            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getMarkersJni as *mut c_void,
10801        },
10802        #[cfg(feature = "sync")]
10803        NativeMethod {
10804            name: "publishMarkerJni".into(),
10805            sig: "(JLjava/lang/String;)Z".into(),
10806            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishMarkerJni as *mut c_void,
10807        },
10808        #[cfg(feature = "sync")]
10809        NativeMethod {
10810            name: "publishDocumentJni".into(),
10811            sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
10812            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishDocumentJni as *mut c_void,
10813        },
10814        #[cfg(feature = "sync")]
10815        NativeMethod {
10816            name: "publishDocumentWithOriginJni".into(),
10817            sig: "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"
10818                .into(),
10819            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishDocumentWithOriginJni
10820                as *mut c_void,
10821        },
10822        #[cfg(all(feature = "sync", feature = "bluetooth"))]
10823        NativeMethod {
10824            name: "ingestPositionJni".into(),
10825            sig: "(JLjava/lang/String;)Ljava/lang/String;".into(),
10826            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestPositionJni as *mut c_void,
10827        },
10828        #[cfg(all(feature = "sync", feature = "bluetooth"))]
10829        NativeMethod {
10830            name: "ingestInboundFrameJni".into(),
10831            sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
10832            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundFrameJni as *mut c_void,
10833        },
10834        #[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10835        NativeMethod {
10836            name: "ingestInboundLiteFrameJni".into(),
10837            sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
10838            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundLiteFrameJni as *mut c_void,
10839        },
10840        #[cfg(feature = "sync")]
10841        NativeMethod {
10842            name: "connectPeerJni".into(),
10843            sig: "(JLjava/lang/String;Ljava/lang/String;)Z".into(),
10844            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectPeerJni as *mut c_void,
10845        },
10846        #[cfg(feature = "sync")]
10847        NativeMethod {
10848            name: "createNodeWithConfigJni".into(),
10849            sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)J"
10850                .into(),
10851            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeWithConfigJni as *mut c_void,
10852        },
10853        // peat#925: the four subscription methods
10854        // (subscribe/unsubscribeDocumentChangesJni,
10855        // subscribe/unsubscribeOutboundFramesJni) are intentionally NOT
10856        // registered via nativeInit because their signatures reference
10857        // consumer-supplied listener interfaces
10858        // (`com/defenseunicorns/peat/DocumentChangeListener`,
10859        // `com/defenseunicorns/peat/OutboundFrameListener`) that don't
10860        // exist in peat-ffi's own `PeatJni.kt` — see the comment block at
10861        // peat-ffi/android/src/main/kotlin/.../PeatJni.kt:27-34 which
10862        // documents the "consumers declare these externs locally" pattern.
10863        //
10864        // The Rust extern fns `Java_com_defenseunicorns_peat_PeatJni_*`
10865        // are still exported and reachable via JNI's auto-lookup-by-name
10866        // convention: any consumer (peat-atak-plugin, downstream apps)
10867        // that declares `external fun subscribeDocumentChangesJni(...)`
10868        // alongside its `DocumentChangeListener` interface gets the
10869        // function resolved via dlsym at first call.
10870        //
10871        // Why these were here: ADR-059 Slice 1.b's outbound-frame
10872        // wiring was developed against a peat-atak-plugin build that
10873        // DID declare the listener interfaces; the `NativeMethod`
10874        // entries were copy-pasted from that build's lockstep
10875        // registration table without re-checking peat-ffi's own
10876        // PeatJni.kt surface.
10877        //
10878        // What went wrong: `JNI_OnLoad → nativeInit → RegisterNatives`
10879        // tries to bind every entry to a corresponding member on
10880        // `com.defenseunicorns.peat.PeatJni`. The DocumentChangeListener
10881        // / OutboundFrameListener signatures reference Kotlin classes
10882        // that don't exist. CheckJNI (active on debug-instrumented
10883        // builds, which is the AndroidJUnit harness configuration on
10884        // the Galaxy Tab A9+ CI runner) aborts the process on
10885        // registration mismatch — `Fatal signal 6 (SIGABRT), code -1
10886        // (SI_QUEUE)` in tid == JUnit-runner-tid, ~12ms after
10887        // `System.loadLibrary("peat_ffi")` returns. The post-
10888        // IrohTransport timing of the abort in earlier logcats was
10889        // misleading — the actual fault is during `System.loadLibrary`
10890        // which the test harness only logs after the abort propagates.
10891        // Blob transfer (ADR-060)
10892        #[cfg(feature = "sync")]
10893        NativeMethod {
10894            name: "enableBlobTransferJni".into(),
10895            sig: "(JLjava/lang/String;)Z".into(),
10896            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_enableBlobTransferJni as *mut c_void,
10897        },
10898        #[cfg(feature = "sync")]
10899        NativeMethod {
10900            name: "blobAddPeerJni".into(),
10901            sig: "(JLjava/lang/String;Ljava/lang/String;)Z".into(),
10902            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobAddPeerJni as *mut c_void,
10903        },
10904        #[cfg(feature = "sync")]
10905        NativeMethod {
10906            name: "blobPutJni".into(),
10907            sig: "(J[BLjava/lang/String;)Ljava/lang/String;".into(),
10908            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobPutJni as *mut c_void,
10909        },
10910        #[cfg(feature = "sync")]
10911        NativeMethod {
10912            name: "blobGetJni".into(),
10913            sig: "(JLjava/lang/String;)[B".into(),
10914            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobGetJni as *mut c_void,
10915        },
10916        #[cfg(feature = "sync")]
10917        NativeMethod {
10918            name: "blobExistsLocallyJni".into(),
10919            sig: "(JLjava/lang/String;)Z".into(),
10920            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobExistsLocallyJni as *mut c_void,
10921        },
10922        #[cfg(feature = "sync")]
10923        NativeMethod {
10924            name: "blobEndpointIdJni".into(),
10925            sig: "(J)Ljava/lang/String;".into(),
10926            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobEndpointIdJni as *mut c_void,
10927        },
10928        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10929        NativeMethod {
10930            name: "bleSetStartedJni".into(),
10931            sig: "(JZ)V".into(),
10932            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleSetStartedJni as *mut c_void,
10933        },
10934        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10935        NativeMethod {
10936            name: "bleAddPeerJni".into(),
10937            sig: "(JLjava/lang/String;)V".into(),
10938            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleAddPeerJni as *mut c_void,
10939        },
10940        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10941        NativeMethod {
10942            name: "bleRemovePeerJni".into(),
10943            sig: "(JLjava/lang/String;)V".into(),
10944            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleRemovePeerJni as *mut c_void,
10945        },
10946        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10947        NativeMethod {
10948            name: "bleIsAvailableJni".into(),
10949            sig: "(J)Z".into(),
10950            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleIsAvailableJni as *mut c_void,
10951        },
10952        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10953        NativeMethod {
10954            name: "blePeerCountJni".into(),
10955            sig: "(J)I".into(),
10956            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blePeerCountJni as *mut c_void,
10957        },
10958    ];
10959
10960    // Register native methods - the class is passed in from Kotlin so it's valid
10961    if let Err(_e) = env.register_native_methods(&class, &methods) {
10962        // Log error but don't crash - caller will see methods not registered
10963        let _ = env.exception_describe();
10964        let _ = env.exception_clear();
10965    }
10966}
10967
10968/// Bridge `tracing` events into android logcat (peat#850).
10969///
10970/// peat-mesh and peat-protocol emit per-doc sync results, transport
10971/// errors, and other diagnostics via `tracing::error!` /
10972/// `tracing::warn!` / `tracing::info!` / `tracing::debug!`. Without
10973/// a subscriber installed these events go nowhere on Android — which
10974/// is how the marker-sync silent-failure bug went un-diagnosed until
10975/// peat-ffi `request_sync` got its own `android_log` (peat#848).
10976///
10977/// This subscriber routes every tracing event matching the filter
10978/// to logcat under the `PeatRust` tag, **with the tracing `Level`
10979/// mapped to the corresponding Android log priority** so
10980/// `adb logcat *:W` / `*:E` priority filtering surfaces peat-mesh's
10981/// `warn!` / `error!` events. Priority mapping (Android NDK
10982/// convention): `ERROR→6, WARN→5, INFO→4, DEBUG→3, TRACE→2`.
10983///
10984/// Implementation uses a custom `tracing_subscriber::Layer<S>` impl
10985/// (not the `fmt-layer` + custom `Write` pipeline) because the
10986/// formatted-bytes interface only sees the rendered string, not the
10987/// originating `Event`'s metadata. The Layer pulls
10988/// `event.metadata().level()` directly and dispatches to
10989/// `__android_log_write` with the mapped priority. peat#851 round-5.
10990///
10991/// Idempotent via `OnceLock` — safe to call multiple times. Failures
10992/// to install (another subscriber already global) are logged once
10993/// and ignored, never panic.
10994///
10995/// The level defaults to INFO; override with `PEAT_TRACING_LEVEL=debug`
10996/// (or any `tracing-subscriber::EnvFilter` directive) at process
10997/// launch via an environment variable on the Android side. Going
10998/// below INFO is verbose — fine for active diagnostic, not for
10999/// steady-state.
11000#[cfg(target_os = "android")]
11001fn init_android_tracing() {
11002    use std::sync::OnceLock;
11003    static INITIALIZED: OnceLock<()> = OnceLock::new();
11004    INITIALIZED.get_or_init(|| {
11005        use std::ffi::CString;
11006        use std::fmt::Write as _;
11007        use std::os::raw::c_char;
11008        use tracing::field::{Field, Visit};
11009        use tracing::{Event, Level, Subscriber};
11010        use tracing_subscriber::layer::{Context, SubscriberExt};
11011        use tracing_subscriber::util::SubscriberInitExt;
11012        use tracing_subscriber::{EnvFilter, Layer};
11013
11014        extern "C" {
11015            fn __android_log_write(prio: i32, tag: *const c_char, text: *const c_char) -> i32;
11016        }
11017
11018        // Tag is a compile-time constant — allocate the CString once
11019        // for the lifetime of the process, not on every log event.
11020        fn tag_ptr() -> *const c_char {
11021            static TAG: OnceLock<CString> = OnceLock::new();
11022            TAG.get_or_init(|| CString::new("PeatRust").expect("static tag"))
11023                .as_ptr()
11024        }
11025
11026        /// Visitor that flattens an event's fields into a single
11027        /// string. Treats the `message` field (where `info!("X")`'s
11028        /// argument lands) specially so it's not prefixed with
11029        /// `message=`. Other fields render as `name=value`.
11030        #[derive(Default)]
11031        struct FieldStringifier(String);
11032        impl Visit for FieldStringifier {
11033            fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
11034                if !self.0.is_empty() {
11035                    self.0.push(' ');
11036                }
11037                if field.name() == "message" {
11038                    // Debug-format strips the surrounding quotes if
11039                    // the value is a `&str` literal, which matches
11040                    // how the fmt-layer rendered messages previously.
11041                    let _ = write!(self.0, "{:?}", value);
11042                } else {
11043                    let _ = write!(self.0, "{}={:?}", field.name(), value);
11044                }
11045            }
11046            fn record_str(&mut self, field: &Field, value: &str) {
11047                if !self.0.is_empty() {
11048                    self.0.push(' ');
11049                }
11050                if field.name() == "message" {
11051                    self.0.push_str(value);
11052                } else {
11053                    let _ = write!(self.0, "{}={}", field.name(), value);
11054                }
11055            }
11056        }
11057
11058        /// `Level → Android NDK priority` mapping. Verbose=2,
11059        /// Debug=3, Info=4, Warn=5, Error=6. Constants live in
11060        /// `android/log.h`; we hardcode them rather than pulling in
11061        /// the `ndk-sys` crate just for five integers.
11062        fn android_priority(level: &Level) -> i32 {
11063            match *level {
11064                Level::ERROR => 6,
11065                Level::WARN => 5,
11066                Level::INFO => 4,
11067                Level::DEBUG => 3,
11068                Level::TRACE => 2,
11069            }
11070        }
11071
11072        struct AndroidLayer;
11073        impl<S: Subscriber> Layer<S> for AndroidLayer {
11074            fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
11075                let metadata = event.metadata();
11076                let prio = android_priority(metadata.level());
11077
11078                let mut visitor = FieldStringifier::default();
11079                event.record(&mut visitor);
11080                // Prefix with the target (typically the source crate
11081                // / module path) so a logcat reader can grep for
11082                // `peat_mesh::storage::automerge_sync` without
11083                // needing the priority signal alone.
11084                let formatted = if visitor.0.is_empty() {
11085                    metadata.target().to_string()
11086                } else {
11087                    format!("{}: {}", metadata.target(), visitor.0)
11088                };
11089
11090                // Cap each entry well under logcat's per-line limit
11091                // (~4 KiB). The source string is valid UTF-8, so we
11092                // must truncate on a char boundary — walk back from
11093                // byte LIMIT to a UTF-8 leading byte. Worst case 3
11094                // bytes back, O(1).
11095                const LIMIT: usize = 3500;
11096                let bytes = formatted.as_bytes();
11097                let truncated: &[u8] = if bytes.len() > LIMIT {
11098                    let mut cut = LIMIT;
11099                    while cut > 0 && (bytes[cut] & 0b1100_0000) == 0b1000_0000 {
11100                        cut -= 1;
11101                    }
11102                    &bytes[..cut]
11103                } else {
11104                    bytes
11105                };
11106
11107                if let Ok(c_msg) = CString::new(truncated) {
11108                    unsafe {
11109                        __android_log_write(prio, tag_ptr(), c_msg.as_ptr());
11110                    }
11111                }
11112            }
11113        }
11114
11115        let env_filter = EnvFilter::try_from_env("PEAT_TRACING_LEVEL")
11116            .unwrap_or_else(|_| EnvFilter::new("info"));
11117
11118        let result = tracing_subscriber::registry()
11119            .with(env_filter)
11120            .with(AndroidLayer)
11121            .try_init();
11122
11123        match result {
11124            Ok(()) => android_log("init_android_tracing: subscriber installed"),
11125            Err(e) => android_log(&format!(
11126                "init_android_tracing: subscriber NOT installed (already set?): {}",
11127                e
11128            )),
11129        }
11130    });
11131}
11132
11133/// Install a `std::panic::set_hook` that writes the panic payload +
11134/// file:line + (best-effort) backtrace to logcat under the `PeatFFI`
11135/// tag before chaining to the default handler. Idempotent via
11136/// `OnceLock`.
11137///
11138/// Why this exists: on Android, the default panic handler writes to
11139/// stderr which logcat never captures, so an `unwrap()` in a worker
11140/// thread aborts the process with only a bionic SIGABRT trace whose
11141/// frames are stripped Rust symbols. With this hook installed, the
11142/// panic message + source location lands in the existing PeatFFI
11143/// logcat stream that AndroidJUnit and `adb logcat` already
11144/// surface.
11145#[cfg(target_os = "android")]
11146fn install_android_panic_hook() {
11147    use std::sync::OnceLock;
11148    static INSTALLED: OnceLock<()> = OnceLock::new();
11149    INSTALLED.get_or_init(|| {
11150        let default_hook = std::panic::take_hook();
11151        std::panic::set_hook(Box::new(move |info| {
11152            let payload = info
11153                .payload()
11154                .downcast_ref::<&str>()
11155                .copied()
11156                .or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
11157                .unwrap_or("<non-string panic payload>");
11158            let location = info
11159                .location()
11160                .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
11161                .unwrap_or_else(|| "<unknown location>".to_string());
11162            let thread = std::thread::current();
11163            let thread_name = thread.name().unwrap_or("<unnamed>");
11164            android_log(&format!(
11165                "PANIC in thread '{}' at {}: {}",
11166                thread_name, location, payload
11167            ));
11168            default_hook(info);
11169        }));
11170        android_log("install_android_panic_hook: panic hook installed");
11171    });
11172}
11173
11174/// JNI_OnLoad - Called when library is loaded via System.loadLibrary()
11175///
11176/// This is our chance to register native methods while we have access to
11177/// the JNI environment from inside the library's linker namespace.
11178#[no_mangle]
11179#[allow(non_snake_case)]
11180#[allow(clippy::not_unsafe_ptr_arg_deref)] // JNI ABI requires raw pointer params
11181pub extern "C" fn JNI_OnLoad(vm: *mut JavaVM, _reserved: *mut c_void) -> jint {
11182    // Log that we're being called
11183    #[cfg(target_os = "android")]
11184    android_log("JNI_OnLoad called for peat_ffi");
11185
11186    // Bridge `tracing` events (peat-mesh's per-doc sync warnings,
11187    // peat-protocol's sync coordinator events, etc.) into logcat
11188    // under the `PeatRust` tag. peat#850 — previous attempts at
11189    // tracing init "caused issues" per the prior comment here; this
11190    // implementation uses a minimal in-process writer with no JNI
11191    // re-entry and `try_init` so it's a no-op if another subscriber
11192    // was already set.
11193    #[cfg(target_os = "android")]
11194    init_android_tracing();
11195
11196    // Forward Rust panics to logcat before the default hook aborts
11197    // the process. Without this, an `unwrap()` deep in a worker
11198    // thread aborts with no diagnostic — Android's default panic
11199    // path writes to stderr which logcat never captures, and the
11200    // process exits via SIGABRT with only a bionic backtrace whose
11201    // frames are stripped Rust symbols. peat#925 follow-on: makes
11202    // future panics in the iroh/rustls/aws-lc-rs/redb code paths
11203    // self-diagnose in the existing PeatFFI logcat tag.
11204    #[cfg(target_os = "android")]
11205    install_android_panic_hook();
11206
11207    // Initialize `ndk-context`'s global JavaVM cell. The crate is
11208    // pulled in transitively by the iroh 1.0.0-rc.0 cascade
11209    // (swarm-discovery / iroh-mdns-address-lookup / iroh-dns →
11210    // hickory-resolver) and panics with "android context was not
11211    // initialized" the first time any Android-aware code in that
11212    // subtree resolves the global context. Without this call,
11213    // every `createNodeJni` SIGABRT's mid-bind. Surfaced by the
11214    // panic hook above:
11215    //   PANIC in thread '<unnamed>' at ndk-context-0.1.1/src/lib.rs:72:
11216    //     android context was not initialized
11217    //
11218    // **Safety boundary of the null-context init below.** We pass
11219    // our `JavaVM*` (definitely available — it's the argument to
11220    // JNI_OnLoad) and `null` for the Android `Context` jobject (NOT
11221    // available from JNI_OnLoad — JNI_OnLoad runs before any
11222    // Application/Activity has been instantiated by the framework).
11223    // Code paths that consult only the JVM (mDNS multicast worker,
11224    // swarm-discovery sender, iroh thread attachment) get served by
11225    // this init alone. Code paths that genuinely need the
11226    // *Context* itself — hickory-resolver's Android system-DNS
11227    // probe via ConnectivityManager, NDK asset-manager access,
11228    // app-private file paths — will hit `ndk_context::android_context().context()`
11229    // and panic on the null. Consumers exercising those paths
11230    // (any iroh deployment using DNS-based discovery — relay, pkarr,
11231    // non-mDNS peer lookups) MUST call `setAndroidContextJni` from
11232    // their `Application.onCreate` before `createNodeJni`. peat-ffi's
11233    // own surface tests don't reach those paths, but a downstream
11234    // consumer hitting them without `setAndroidContextJni` would
11235    // get a `PANIC in thread '<unnamed>' at ndk-context-0.1.1/...:
11236    // android context was not initialized` line via the panic hook
11237    // above and a SIGABRT — same diagnostic the null-context
11238    // discovery in this very PR surfaced. peat#925 QA WARNING-1.
11239    #[cfg(target_os = "android")]
11240    unsafe {
11241        ndk_context::initialize_android_context(vm as *mut c_void, std::ptr::null_mut());
11242        android_log("JNI_OnLoad: ndk_context::initialize_android_context(vm, null) done");
11243    }
11244
11245    // Store JavaVM globally for callbacks from any thread
11246    let java_vm = unsafe {
11247        match jni::JavaVM::from_raw(vm) {
11248            Ok(jvm) => jvm,
11249            Err(_) => {
11250                #[cfg(target_os = "android")]
11251                android_log("JNI_OnLoad: Failed to create JavaVM from raw pointer");
11252                return jni::sys::JNI_ERR;
11253            }
11254        }
11255    };
11256    *JAVA_VM.lock().unwrap() = Some(java_vm);
11257
11258    // Get JNIEnv from JavaVM
11259    let mut env = unsafe {
11260        let mut env_ptr: *mut jni::sys::JNIEnv = std::ptr::null_mut();
11261        let get_env_result = (**vm).GetEnv.unwrap()(
11262            vm,
11263            &mut env_ptr as *mut _ as *mut *mut c_void,
11264            JNI_VERSION_1_6 as i32,
11265        );
11266        if get_env_result != jni::sys::JNI_OK as i32 {
11267            #[cfg(target_os = "android")]
11268            android_log("JNI_OnLoad: GetEnv failed");
11269            return jni::sys::JNI_ERR;
11270        }
11271        match JNIEnv::from_raw(env_ptr) {
11272            Ok(env) => env,
11273            Err(_) => {
11274                #[cfg(target_os = "android")]
11275                android_log("JNI_OnLoad: JNIEnv::from_raw failed");
11276                return jni::sys::JNI_ERR;
11277            }
11278        }
11279    };
11280
11281    // Try to find PeerEventManager class and store global reference for callbacks
11282    let peer_event_manager_class = "com/defenseunicorns/peat/PeerEventManager";
11283    match env.find_class(peer_event_manager_class) {
11284        Ok(class) => match env.new_global_ref(class) {
11285            Ok(global_ref) => {
11286                *PEER_EVENT_MANAGER_CLASS.lock().unwrap() = Some(global_ref);
11287                #[cfg(target_os = "android")]
11288                android_log("JNI_OnLoad: PeerEventManager class found and cached");
11289            }
11290            Err(_) => {
11291                #[cfg(target_os = "android")]
11292                android_log("JNI_OnLoad: Failed to create global ref for PeerEventManager");
11293            }
11294        },
11295        Err(_) => {
11296            // CRITICAL: clear the pending ClassNotFoundException
11297            // before any further JNI call. Without this, the very
11298            // next find_class (for PeatJni at line 9418) detects a
11299            // pending exception and the JNI runtime aborts the
11300            // process with SIGABRT. Consumers that don't ship a
11301            // PeerEventManager (anything other than peat-atak-plugin)
11302            // crash at System.loadLibrary("peat_ffi"). Surfaced by
11303            // peat-mesh#145 / peat#887.
11304            let _ = env.exception_clear();
11305            #[cfg(target_os = "android")]
11306            android_log(
11307                "JNI_OnLoad: PeerEventManager class not found (OK if loading before class init)",
11308            );
11309        }
11310    }
11311
11312    #[cfg(target_os = "android")]
11313    android_log("JNI_OnLoad: Got JNIEnv, looking for PeatJni class...");
11314
11315    // Try to find the PeatJni class and register natives
11316    let class_name = "com/defenseunicorns/peat/PeatJni";
11317    match env.find_class(class_name) {
11318        Ok(class) => {
11319            #[cfg(target_os = "android")]
11320            android_log("JNI_OnLoad: Found PeatJni class, registering natives...");
11321
11322            // Register native methods
11323            use jni::NativeMethod;
11324            let methods: Vec<NativeMethod> = vec![
11325                NativeMethod {
11326                    name: "nativeInit".into(),
11327                    sig: "()V".into(),
11328                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_nativeInit as *mut c_void,
11329                },
11330                NativeMethod {
11331                    name: "peatVersion".into(),
11332                    sig: "()Ljava/lang/String;".into(),
11333                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peatVersion as *mut c_void,
11334                },
11335                NativeMethod {
11336                    name: "testJni".into(),
11337                    sig: "()Ljava/lang/String;".into(),
11338                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_testJni as *mut c_void,
11339                },
11340                #[cfg(target_os = "android")]
11341                NativeMethod {
11342                    name: "setAndroidContextJni".into(),
11343                    sig: "(Ljava/lang/Object;)V".into(),
11344                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_setAndroidContextJni
11345                        as *mut c_void,
11346                },
11347                #[cfg(target_os = "android")]
11348                NativeMethod {
11349                    name: "verifyAndroidContextJni".into(),
11350                    sig: "()Z".into(),
11351                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_verifyAndroidContextJni
11352                        as *mut c_void,
11353                },
11354                #[cfg(feature = "sync")]
11355                NativeMethod {
11356                    name: "createNodeJni".into(),
11357                    sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)J".into(),
11358                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeJni as *mut c_void,
11359                },
11360                #[cfg(feature = "sync")]
11361                NativeMethod {
11362                    name: "getGlobalNodeHandleJni".into(),
11363                    sig: "()J".into(),
11364                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getGlobalNodeHandleJni
11365                        as *mut c_void,
11366                },
11367                #[cfg(feature = "sync")]
11368                NativeMethod {
11369                    name: "clearGlobalNodeHandleJni".into(),
11370                    sig: "()V".into(),
11371                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_clearGlobalNodeHandleJni
11372                        as *mut c_void,
11373                },
11374                #[cfg(feature = "sync")]
11375                NativeMethod {
11376                    name: "nodeIdJni".into(),
11377                    sig: "(J)Ljava/lang/String;".into(),
11378                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_nodeIdJni as *mut c_void,
11379                },
11380                #[cfg(feature = "sync")]
11381                NativeMethod {
11382                    name: "peerCountJni".into(),
11383                    sig: "(J)I".into(),
11384                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peerCountJni as *mut c_void,
11385                },
11386                #[cfg(feature = "sync")]
11387                NativeMethod {
11388                    name: "connectedPeersJni".into(),
11389                    sig: "(J)Ljava/lang/String;".into(),
11390                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectedPeersJni as *mut c_void,
11391                },
11392                #[cfg(feature = "sync")]
11393                NativeMethod {
11394                    name: "requestSyncJni".into(),
11395                    sig: "(J)Z".into(),
11396                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_requestSyncJni as *mut c_void,
11397                },
11398                #[cfg(feature = "sync")]
11399                NativeMethod {
11400                    name: "endpointSocketAddrJni".into(),
11401                    sig: "(J)Ljava/lang/String;".into(),
11402                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_endpointSocketAddrJni
11403                        as *mut c_void,
11404                },
11405                #[cfg(feature = "sync")]
11406                NativeMethod {
11407                    name: "getDocumentJni".into(),
11408                    sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
11409                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getDocumentJni as *mut c_void,
11410                },
11411                #[cfg(feature = "sync")]
11412                NativeMethod {
11413                    name: "forceStoreErrorForTestingJni".into(),
11414                    sig: "(J)Z".into(),
11415                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_forceStoreErrorForTestingJni
11416                        as *mut c_void,
11417                },
11418                #[cfg(feature = "sync")]
11419                NativeMethod {
11420                    name: "startSyncJni".into(),
11421                    sig: "(J)Z".into(),
11422                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_startSyncJni as *mut c_void,
11423                },
11424                #[cfg(feature = "sync")]
11425                NativeMethod {
11426                    name: "freeNodeJni".into(),
11427                    sig: "(J)V".into(),
11428                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_freeNodeJni as *mut c_void,
11429                },
11430                #[cfg(feature = "sync")]
11431                NativeMethod {
11432                    name: "getCellsJni".into(),
11433                    sig: "(J)Ljava/lang/String;".into(),
11434                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCellsJni as *mut c_void,
11435                },
11436                #[cfg(feature = "sync")]
11437                NativeMethod {
11438                    name: "getTracksJni".into(),
11439                    sig: "(J)Ljava/lang/String;".into(),
11440                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getTracksJni as *mut c_void,
11441                },
11442                #[cfg(feature = "sync")]
11443                NativeMethod {
11444                    name: "getNodesJni".into(),
11445                    sig: "(J)Ljava/lang/String;".into(),
11446                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getNodesJni as *mut c_void,
11447                },
11448                #[cfg(feature = "sync")]
11449                NativeMethod {
11450                    name: "getCommandsJni".into(),
11451                    sig: "(J)Ljava/lang/String;".into(),
11452                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCommandsJni as *mut c_void,
11453                },
11454                #[cfg(feature = "sync")]
11455                NativeMethod {
11456                    name: "getMarkersJni".into(),
11457                    sig: "(J)Ljava/lang/String;".into(),
11458                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getMarkersJni as *mut c_void,
11459                },
11460                #[cfg(feature = "sync")]
11461                NativeMethod {
11462                    name: "publishMarkerJni".into(),
11463                    sig: "(JLjava/lang/String;)Z".into(),
11464                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishMarkerJni
11465                        as *mut c_void,
11466                },
11467                #[cfg(feature = "sync")]
11468                NativeMethod {
11469                    name: "publishNodeJni".into(),
11470                    sig: "(JLjava/lang/String;)Z".into(),
11471                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishNodeJni
11472                        as *mut c_void,
11473                },
11474                #[cfg(feature = "sync")]
11475                NativeMethod {
11476                    name: "publishDocumentJni".into(),
11477                    sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
11478                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishDocumentJni
11479                        as *mut c_void,
11480                },
11481                #[cfg(feature = "sync")]
11482                NativeMethod {
11483                    name: "publishDocumentWithOriginJni".into(),
11484                    sig: "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)\
11485                          Ljava/lang/String;"
11486                        .into(),
11487                    fn_ptr:
11488                        Java_com_defenseunicorns_peat_PeatJni_publishDocumentWithOriginJni
11489                            as *mut c_void,
11490                },
11491                #[cfg(all(feature = "sync", feature = "bluetooth"))]
11492                NativeMethod {
11493                    name: "ingestPositionJni".into(),
11494                    sig: "(JLjava/lang/String;)Ljava/lang/String;".into(),
11495                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestPositionJni
11496                        as *mut c_void,
11497                },
11498                #[cfg(all(feature = "sync", feature = "bluetooth"))]
11499                NativeMethod {
11500                    name: "ingestInboundFrameJni".into(),
11501                    sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
11502                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundFrameJni
11503                        as *mut c_void,
11504                },
11505                #[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
11506                NativeMethod {
11507                    name: "ingestInboundLiteFrameJni".into(),
11508                    sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
11509                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundLiteFrameJni
11510                        as *mut c_void,
11511                },
11512                #[cfg(feature = "sync")]
11513                NativeMethod {
11514                    name: "connectPeerJni".into(),
11515                    sig: "(JLjava/lang/String;Ljava/lang/String;)Z".into(),
11516                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectPeerJni as *mut c_void,
11517                },
11518                #[cfg(feature = "sync")]
11519                NativeMethod {
11520                    name: "createNodeWithConfigJni".into(),
11521                    sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)J"
11522                        .into(),
11523                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeWithConfigJni
11524                        as *mut c_void,
11525                },
11526                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11527                NativeMethod {
11528                    name: "bleSetStartedJni".into(),
11529                    sig: "(JZ)V".into(),
11530                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleSetStartedJni as *mut c_void,
11531                },
11532                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11533                NativeMethod {
11534                    name: "bleAddPeerJni".into(),
11535                    sig: "(JLjava/lang/String;)V".into(),
11536                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleAddPeerJni as *mut c_void,
11537                },
11538                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11539                NativeMethod {
11540                    name: "bleRemovePeerJni".into(),
11541                    sig: "(JLjava/lang/String;)V".into(),
11542                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleRemovePeerJni as *mut c_void,
11543                },
11544                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11545                NativeMethod {
11546                    name: "bleIsAvailableJni".into(),
11547                    sig: "(J)Z".into(),
11548                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleIsAvailableJni as *mut c_void,
11549                },
11550                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11551                NativeMethod {
11552                    name: "blePeerCountJni".into(),
11553                    sig: "(J)I".into(),
11554                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blePeerCountJni as *mut c_void,
11555                },
11556            ];
11557
11558            match env.register_native_methods(&class, &methods) {
11559                Ok(_) => {
11560                    #[cfg(target_os = "android")]
11561                    android_log("JNI_OnLoad: Native methods registered successfully!");
11562                }
11563                Err(_) => {
11564                    #[cfg(target_os = "android")]
11565                    android_log("JNI_OnLoad: Failed to register native methods");
11566                    let _ = env.exception_describe();
11567                    let _ = env.exception_clear();
11568                }
11569            }
11570        }
11571        Err(_) => {
11572            #[cfg(target_os = "android")]
11573            android_log(
11574                "JNI_OnLoad: PeatJni class not found (this is OK if loading before class init)",
11575            );
11576            // Class not loaded yet - this is OK, nativeInit will be called
11577            // later
11578        }
11579    }
11580
11581    JNI_VERSION_1_6
11582}
11583
11584/// Log to Android logcat
11585#[cfg(target_os = "android")]
11586fn android_log(msg: &str) {
11587    use std::ffi::CString;
11588    use std::os::raw::c_char;
11589
11590    let tag = CString::new("PeatFFI").unwrap();
11591    let msg = CString::new(msg).unwrap();
11592
11593    unsafe {
11594        // Android log priority INFO = 4
11595        extern "C" {
11596            fn __android_log_write(prio: i32, tag: *const c_char, text: *const c_char) -> i32;
11597        }
11598        __android_log_write(4, tag.as_ptr(), msg.as_ptr());
11599    }
11600}
11601
11602/// Notify Java PeerEventManager of a peer connected event
11603#[cfg(feature = "sync")]
11604fn notify_peer_connected(peer_id: &str) {
11605    notify_peer_event("notifyPeerConnected", peer_id, None);
11606}
11607
11608/// Notify Java PeerEventManager of a peer disconnected event
11609#[cfg(feature = "sync")]
11610fn notify_peer_disconnected(peer_id: &str, reason: &str) {
11611    notify_peer_event("notifyPeerDisconnected", peer_id, Some(reason));
11612}
11613
11614/// Helper to call PeerEventManager static methods
11615#[cfg(feature = "sync")]
11616fn notify_peer_event(method_name: &str, peer_id: &str, reason: Option<&str>) {
11617    let java_vm_guard = JAVA_VM.lock().unwrap();
11618    let java_vm = match java_vm_guard.as_ref() {
11619        Some(vm) => vm,
11620        None => {
11621            #[cfg(target_os = "android")]
11622            android_log("notify_peer_event: No JavaVM available");
11623            return;
11624        }
11625    };
11626
11627    // Check if we already have the class cached
11628    let mut class_guard = PEER_EVENT_MANAGER_CLASS.lock().unwrap();
11629
11630    // If not cached, try to find it now (lazy loading)
11631    if class_guard.is_none() {
11632        #[cfg(target_os = "android")]
11633        android_log("notify_peer_event: PeerEventManager class not cached, trying to find it...");
11634
11635        // Attach current thread to get env for class lookup
11636        if let Ok(mut env) = java_vm.attach_current_thread() {
11637            let peer_event_manager_class = "com/defenseunicorns/peat/PeerEventManager";
11638            if let Ok(class) = env.find_class(peer_event_manager_class) {
11639                if let Ok(global_ref) = env.new_global_ref(class) {
11640                    *class_guard = Some(global_ref);
11641                    #[cfg(target_os = "android")]
11642                    android_log("notify_peer_event: PeerEventManager class found and cached!");
11643                }
11644            } else {
11645                // Clear the pending ClassNotFoundException for the
11646                // same reason as the JNI_OnLoad branch above
11647                // (peat#887). A consumer without PeerEventManager
11648                // is fine — peer events just don't get notified.
11649                let _ = env.exception_clear();
11650                #[cfg(target_os = "android")]
11651                android_log("notify_peer_event: PeerEventManager class not found");
11652            }
11653        }
11654    }
11655
11656    let class_ref = match class_guard.as_ref() {
11657        Some(c) => c,
11658        None => {
11659            #[cfg(target_os = "android")]
11660            android_log("notify_peer_event: PeerEventManager class not available");
11661            return;
11662        }
11663    };
11664
11665    // Attach current thread to JVM
11666    let mut env = match java_vm.attach_current_thread() {
11667        Ok(env) => env,
11668        Err(e) => {
11669            #[cfg(target_os = "android")]
11670            android_log(&format!(
11671                "notify_peer_event: Failed to attach thread: {:?}",
11672                e
11673            ));
11674            return;
11675        }
11676    };
11677
11678    // Create Java string for peer_id
11679    let peer_id_jstring = match env.new_string(peer_id) {
11680        Ok(s) => s,
11681        Err(_) => {
11682            #[cfg(target_os = "android")]
11683            android_log("notify_peer_event: Failed to create peer_id string");
11684            return;
11685        }
11686    };
11687
11688    // Call the appropriate method
11689    let result = if let Some(reason) = reason {
11690        // notifyPeerDisconnected(String peerId, String reason)
11691        let reason_jstring = match env.new_string(reason) {
11692            Ok(s) => s,
11693            Err(_) => {
11694                #[cfg(target_os = "android")]
11695                android_log("notify_peer_event: Failed to create reason string");
11696                return;
11697            }
11698        };
11699        env.call_static_method(
11700            class_ref,
11701            method_name,
11702            "(Ljava/lang/String;Ljava/lang/String;)V",
11703            &[
11704                JValue::Object(&peer_id_jstring),
11705                JValue::Object(&reason_jstring),
11706            ],
11707        )
11708    } else {
11709        // notifyPeerConnected(String peerId)
11710        env.call_static_method(
11711            class_ref,
11712            method_name,
11713            "(Ljava/lang/String;)V",
11714            &[JValue::Object(&peer_id_jstring)],
11715        )
11716    };
11717
11718    if let Err(e) = result {
11719        #[cfg(target_os = "android")]
11720        android_log(&format!("notify_peer_event: Method call failed: {:?}", e));
11721        let _ = env.exception_describe();
11722        let _ = env.exception_clear();
11723    } else {
11724        #[cfg(target_os = "android")]
11725        android_log(&format!(
11726            "notify_peer_event: {} called for {}",
11727            method_name, peer_id
11728        ));
11729    }
11730}