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}
367
368/// Configuration for creating a PeatNode
369#[cfg(feature = "sync")]
370#[derive(Debug, Clone, uniffi::Record)]
371pub struct NodeConfig {
372    /// Application/Formation ID (used for peer discovery and authentication)
373    /// This identifies which "formation" or "swarm" this node belongs to.
374    pub app_id: String,
375    /// Shared secret key (base64-encoded 32 bytes) for peer authentication
376    /// Only peers with matching app_id AND shared_key can connect.
377    /// Generate with: `openssl rand -base64 32`
378    pub shared_key: String,
379    /// Bind address for P2P connections (e.g., "0.0.0.0:0" for auto-assign)
380    pub bind_address: Option<String>,
381    /// Storage path for Automerge documents
382    pub storage_path: String,
383    /// Transport configuration (optional, defaults to Iroh-only)
384    /// Use this to enable BLE and configure multi-transport behavior
385    pub transport: Option<TransportConfigFFI>,
386}
387
388/// Information about a peer node for connection
389#[cfg(feature = "sync")]
390#[derive(Debug, Clone, uniffi::Record)]
391pub struct PeerInfo {
392    /// Human-readable peer name
393    pub name: String,
394    /// Hex-encoded node ID (Iroh endpoint ID)
395    pub node_id: String,
396    /// List of addresses (e.g., "127.0.0.1:19001")
397    pub addresses: Vec<String>,
398    /// Optional relay URL
399    pub relay_url: Option<String>,
400}
401
402/// Sync statistics
403#[cfg(feature = "sync")]
404#[derive(Debug, Clone, uniffi::Record)]
405pub struct SyncStats {
406    /// Whether sync is currently active
407    pub sync_active: bool,
408    /// Number of connected peers
409    pub connected_peers: u32,
410    /// Total bytes sent
411    pub bytes_sent: u64,
412    /// Total bytes received
413    pub bytes_received: u64,
414}
415
416// =============================================================================
417// ADR-032 §Amendment A — Per-Peer Transport State (UniFFI surface)
418// =============================================================================
419//
420// Mirror types over `peat_mesh::transport::LinkState` family. The
421// peat-mesh types aren't UniFFI-decorated (they live in the transport
422// layer, not the binding layer), so we re-shape them into peat-ffi
423// `Record`s/`Enum`s with `From<peat_mesh::...>` conversions. Kotlin
424// plugin consumers render directly off these.
425//
426// Per ADR-032 §Amendment A's host-rendering rule, peat-ffi is the
427// *single source of truth* for transport-state queries in the UI; the
428// plugin MUST NOT reach into peat-btle's UniFFI directly for this
429// purpose. The unified loop walks `TransportManager`, calls
430// `peer_link_state` on each registered transport, and overlays
431// `transport_id` from the registered id (interface overlay is a
432// follow-up — `TransportManager` doesn't yet expose a public
433// instance-metadata accessor).
434
435/// Per-peer transport state across all registered transports.
436///
437/// Returned by [`PeatNode::peer_transport_state`] and contained in the
438/// list returned by [`PeatNode::all_peer_transport_states`]. An empty
439/// `links` vec is a valid state and means "this peer is not currently
440/// reachable via any registered transport" — visualization should
441/// render the peer with no transport badges, not as an error.
442#[cfg(feature = "sync")]
443#[derive(Debug, Clone, uniffi::Record)]
444pub struct PeerTransportState {
445    /// Hex-encoded peer node identifier (matches the form produced by
446    /// `PeatNode::node_id` and `PeatNode::connected_peers`).
447    pub peer_id: String,
448    /// Links for each transport that currently has a record of this
449    /// peer. Order is implementation-defined (usually
450    /// `TransportManager`'s registration order). An empty list is
451    /// valid — see struct docs.
452    pub links: Vec<TransportLink>,
453}
454
455/// One transport's link state for a peer (FFI mirror of
456/// `peat_mesh::transport::LinkState`).
457#[cfg(feature = "sync")]
458#[derive(Debug, Clone, uniffi::Record)]
459pub struct TransportLink {
460    /// Identifies the registered transport instance, e.g. `"ble-hci0"`,
461    /// `"iroh-wlan0"`. Per ADR-032 §Amendment A, peat-ffi overlays this
462    /// from the `TransportManager`-registered id at synthesis time.
463    pub transport_id: String,
464    /// Transport family, lowercase string for cross-language
465    /// portability (`"ble"` / `"iroh"` / `"lora"` / `"satellite"` / …).
466    pub transport_type: String,
467    /// Physical interface name where applicable (`eth0`, `wlan0`,
468    /// `p2p-wlan0`). `None` for transports that don't expose a NIC
469    /// concept (e.g. BLE, LoRa).
470    pub interface: Option<String>,
471    /// Bucketed quality. Each transport defines its own thresholds.
472    pub quality: TransportLinkQuality,
473    /// Round-trip-time estimate in milliseconds, where the transport
474    /// can measure or estimate it.
475    pub rtt_ms: Option<u32>,
476    /// Received signal strength in dBm, populated by transports that
477    /// expose it (BLE, LoRa, tactical radio). `None` for IP transports.
478    pub rssi_dbm: Option<i8>,
479    /// Path classification for IP-style transports with a relay
480    /// concept (iroh's `PathInfo::is_relay()`). `None` where the
481    /// concept doesn't apply (BLE).
482    pub path_kind: Option<TransportPathKind>,
483}
484
485/// Bucketed link quality for UI tier indicators.
486#[cfg(feature = "sync")]
487#[derive(Debug, Clone, Copy, uniffi::Enum)]
488pub enum TransportLinkQuality {
489    Excellent,
490    Good,
491    Fair,
492    Weak,
493    Unknown,
494}
495
496/// Connection path classification.
497///
498/// `Mixed` (multi-path concurrent) was considered during ADR-032
499/// §Amendment A and intentionally deferred until a real emitter exists.
500#[cfg(feature = "sync")]
501#[derive(Debug, Clone, Copy, uniffi::Enum)]
502pub enum TransportPathKind {
503    Direct,
504    Relay,
505}
506
507#[cfg(feature = "sync")]
508impl From<peat_mesh::transport::LinkQuality> for TransportLinkQuality {
509    fn from(q: peat_mesh::transport::LinkQuality) -> Self {
510        match q {
511            peat_mesh::transport::LinkQuality::Excellent => TransportLinkQuality::Excellent,
512            peat_mesh::transport::LinkQuality::Good => TransportLinkQuality::Good,
513            peat_mesh::transport::LinkQuality::Fair => TransportLinkQuality::Fair,
514            peat_mesh::transport::LinkQuality::Weak => TransportLinkQuality::Weak,
515            peat_mesh::transport::LinkQuality::Unknown => TransportLinkQuality::Unknown,
516        }
517    }
518}
519
520#[cfg(feature = "sync")]
521impl From<peat_mesh::transport::PathKind> for TransportPathKind {
522    fn from(p: peat_mesh::transport::PathKind) -> Self {
523        match p {
524            peat_mesh::transport::PathKind::Direct => TransportPathKind::Direct,
525            peat_mesh::transport::PathKind::Relay => TransportPathKind::Relay,
526        }
527    }
528}
529
530#[cfg(feature = "sync")]
531impl From<peat_mesh::transport::LinkState> for TransportLink {
532    fn from(s: peat_mesh::transport::LinkState) -> Self {
533        // `transport_type` to lowercase string — the ADR's enum names
534        // (BluetoothLE, Quic, etc.) are descriptive but don't match the
535        // string form callers tend to use ("ble", "iroh"). Map
536        // explicitly so a future enum-variant addition is a compile-
537        // time prompt to extend this map rather than silently emitting
538        // a Debug-formatted string.
539        let transport_type = match s.transport_type {
540            peat_mesh::transport::TransportType::BluetoothLE => "ble".to_string(),
541            peat_mesh::transport::TransportType::Quic => "iroh".to_string(),
542            peat_mesh::transport::TransportType::LoRa => "lora".to_string(),
543            peat_mesh::transport::TransportType::WifiDirect => "wifi-direct".to_string(),
544            peat_mesh::transport::TransportType::TacticalRadio => "tactical-radio".to_string(),
545            peat_mesh::transport::TransportType::Satellite => "satellite".to_string(),
546            peat_mesh::transport::TransportType::BluetoothClassic => {
547                "bluetooth-classic".to_string()
548            }
549            peat_mesh::transport::TransportType::Custom(n) => format!("custom-{n}"),
550        };
551        TransportLink {
552            transport_id: s.transport_id,
553            transport_type,
554            interface: s.interface,
555            quality: s.quality.into(),
556            rtt_ms: s.rtt_ms,
557            rssi_dbm: s.rssi_dbm,
558            path_kind: s.path_kind.map(Into::into),
559        }
560    }
561}
562
563/// Type of document change event
564#[cfg(feature = "sync")]
565#[derive(Debug, Clone, uniffi::Enum)]
566pub enum ChangeType {
567    /// Document was created or updated
568    Upsert,
569    /// Document was deleted
570    Delete,
571}
572
573/// Document change event for subscriptions
574#[cfg(feature = "sync")]
575#[derive(Debug, Clone, uniffi::Record)]
576pub struct DocumentChange {
577    /// Collection name
578    pub collection: String,
579    /// Document ID
580    pub doc_id: String,
581    /// Type of change
582    pub change_type: ChangeType,
583}
584
585/// Encoded BLE outbound frame produced by the `BleTranslator` fan-out.
586///
587/// Received by calling [`PeatNode::poll_outbound_frames`] on the host side.
588/// The host is responsible for the final transport-specific framing (GATT
589/// write, encryption envelope) before putting `bytes` on the radio.
590#[cfg(all(feature = "sync", feature = "bluetooth"))]
591#[derive(Debug, Clone, uniffi::Record)]
592pub struct OutboundFrame {
593    /// Transport identifier — `"ble"` for typed 0xB6 frames, `"ble-lite"`
594    /// for universal-Document (peat-lite) frames.
595    pub transport_id: String,
596    /// Collection the document belongs to (e.g. `"tracks"`, `"platforms"`).
597    pub collection: String,
598    /// postcard-encoded typed BLE struct ready for the radio.
599    pub bytes: Vec<u8>,
600}
601
602/// Callback interface for document change notifications
603///
604/// Implement this interface in Kotlin/Swift to receive document updates.
605#[cfg(feature = "sync")]
606#[uniffi::export(callback_interface)]
607pub trait DocumentCallback: Send + Sync {
608    /// Called when a document changes
609    fn on_change(&self, change: DocumentChange);
610
611    /// Called when an error occurs in the subscription
612    fn on_error(&self, message: String);
613}
614
615/// Outbound transport-frame callback for non-Android platforms (iOS via
616/// UniFFI). Mirrors the Android `OutboundFrameListener` JNI surface
617/// (`subscribeOutboundFramesJni`); the trait method receives the same
618/// `(transport_id, collection, bytes)` triple per encoded document.
619///
620/// On Android the JNI path is used directly because UniFFI 0.28's Kotlin
621/// backend wraps callback interfaces in `com.sun.jna.Callback`, which
622/// fails under Android plugin-host classloader isolation. Implementations
623/// on non-Android platforms should expect any-thread invocation from the
624/// `peat-mesh` runtime.
625///
626/// The `register_outbound_frame_callback` method on [`PeatNode`] that
627/// would consume this trait is deferred to a follow-up: the
628/// `Drop`-vs-async `unregister_translator` interaction needs an
629/// `Arc<TransportManager>` refactor of `PeatNode` to be done cleanly
630/// (current `TransportManager` field is owned, not Arc-wrapped, so a
631/// subscription handle has no clean way to drive teardown on drop).
632/// The trait declaration here serves as documentation of the iOS-side
633/// shape so the follow-up can land without an FFI break.
634#[cfg(all(feature = "sync", feature = "bluetooth"))]
635#[uniffi::export(callback_interface)]
636pub trait OutboundFrameCallback: Send + Sync {
637    fn on_frame(&self, transport_id: String, collection: String, bytes: Vec<u8>);
638}
639
640/// Handle for an active document subscription
641///
642/// Drop this handle to unsubscribe from document changes.
643#[cfg(feature = "sync")]
644#[derive(uniffi::Object)]
645pub struct SubscriptionHandle {
646    active: Arc<AtomicBool>,
647    /// Queued changes for polling consumers (populated by `subscribe_poll`).
648    pending: Arc<std::sync::Mutex<std::collections::VecDeque<DocumentChange>>>,
649}
650
651#[cfg(feature = "sync")]
652impl SubscriptionHandle {
653    fn new(active: Arc<AtomicBool>) -> Self {
654        Self {
655            active,
656            pending: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
657        }
658    }
659
660    fn new_with_queue(
661        active: Arc<AtomicBool>,
662        pending: Arc<std::sync::Mutex<std::collections::VecDeque<DocumentChange>>>,
663    ) -> Self {
664        Self { active, pending }
665    }
666}
667
668#[cfg(feature = "sync")]
669#[uniffi::export]
670impl SubscriptionHandle {
671    /// Check if the subscription is still active
672    pub fn is_active(&self) -> bool {
673        self.active.load(Ordering::SeqCst)
674    }
675
676    /// Cancel the subscription
677    pub fn cancel(&self) {
678        self.active.store(false, Ordering::SeqCst);
679    }
680
681    /// Drain all pending document changes. Non-blocking.
682    ///
683    /// Only populated when the subscription was opened via
684    /// [`PeatNode::subscribe_poll`]. Always returns an empty Vec for
685    /// subscriptions opened via [`PeatNode::subscribe`] (callback path).
686    pub fn poll_changes(&self) -> Vec<DocumentChange> {
687        self.pending
688            .lock()
689            .map(|mut q| q.drain(..).collect())
690            .unwrap_or_default()
691    }
692}
693
694#[cfg(feature = "sync")]
695impl Drop for SubscriptionHandle {
696    fn drop(&mut self) {
697        self.active.store(false, Ordering::SeqCst);
698    }
699}
700
701/// A Peat network node with P2P sync capabilities
702///
703/// Wraps AutomergeIrohBackend for authenticated document sync.
704/// Requires matching app_id and shared_key for peer connections.
705#[cfg(feature = "sync")]
706#[derive(uniffi::Object)]
707pub struct PeatNode {
708    /// The sync backend with FormationKey authentication
709    sync_backend: Arc<AutomergeIrohBackend>,
710    /// Storage backend for document operations (shared with sync_backend)
711    /// Note: This is the SAME backend instance used by sync_backend to ensure
712    /// sync coordinator state is shared. Do NOT create a separate backend.
713    storage_backend: Arc<AutomergeBackend>,
714    /// Generic application-level mesh document layer wrapping `sync_backend`.
715    /// Composed alongside the existing typed surface (nodes, cells,
716    /// tracks, …) so callers can reach generic publish/get/query/observe
717    /// without going through type-specific JNI methods. Foundation step 3 of
718    /// the peat-mesh-completion / peat-btle-reduction work — see
719    /// `PEAT-MESH-COMPLETION-0.9.0.md`.
720    #[cfg(feature = "sync")]
721    node: Arc<peat_mesh::Node>,
722    /// peat-protocol's [`BleTranslator`] (ADR-041) used by the `ingest*Jni`
723    /// family of methods. Translates typed BLE structs to Automerge
724    /// documents; the result is published into [`Self::node`] with
725    /// `Some("ble")` origin so ADR-059's same-node echo suppression keeps
726    /// the doc from being re-encoded back out to BLE. The earlier
727    /// `BleGateway` wrapper composing translator + node was removed in
728    /// Slice 1.b.2.2 — composition happens inline in the JNI helpers
729    /// because peat-ffi owns both halves anyway, so the wrapper added no
730    /// boundary worth defending.
731    ///
732    /// [`BleTranslator`]: peat_protocol::sync::ble_translation::BleTranslator
733    #[cfg(all(feature = "sync", feature = "bluetooth"))]
734    ble_translator: Arc<peat_protocol::sync::ble_translation::BleTranslator>,
735    /// Transport manager for multi-transport coordination (ADR-032)
736    /// Enables PACE policy-based transport selection and future BLE integration
737    transport_manager: TransportManager,
738    /// Direct reference to Iroh transport for backward-compatible methods
739    /// (peer_count, connected_peers, etc.)
740    iroh_transport: Arc<IrohTransport>,
741    /// Store reference for subscriptions
742    store: Arc<AutomergeStore>,
743    #[allow(dead_code)] // Kept for potential future use (e.g., storage cleanup)
744    storage_path: PathBuf,
745    /// Tokio runtime for async operations
746    runtime: Arc<tokio::runtime::Runtime>,
747    /// Flag to stop cleanup task on drop (used by background task)
748    #[allow(dead_code)]
749    cleanup_running: Arc<AtomicBool>,
750    /// Optional blob store running on a parallel iroh endpoint (ADR-060).
751    /// None when blob transfer is disabled — this is the common case for
752    /// sim nodes that don't need to serve or fetch binary payloads.
753    /// Constructed via PeatNode::enable_blob_transfer() after node creation.
754    #[cfg(feature = "sync")]
755    blob_store: std::sync::RwLock<Option<Arc<NetworkedIrohBlobStore>>>,
756    /// Queue of outbound BLE frames produced by the `BleTranslator` fan-out.
757    /// Populated by `QueueOutboundSink::send_outbound`; drained by
758    /// `poll_outbound_frames`. None when the `bluetooth` feature is off.
759    #[cfg(all(feature = "sync", feature = "bluetooth"))]
760    outbound_queue: Arc<std::sync::Mutex<std::collections::VecDeque<OutboundFrame>>>,
761    /// `FanoutHandle` for the active outbound subscription, if any.
762    /// Held alive between `start_outbound_frames` and `stop_outbound_frames`.
763    #[cfg(all(feature = "sync", feature = "bluetooth"))]
764    outbound_fanout: std::sync::Mutex<Option<peat_mesh::transport::FanoutHandle>>,
765    /// Dedup set for BLE multi-hop relay: frame-hash -> last-relayed instant.
766    ///
767    /// peat-mesh's fan-out re-fans an ingested frame to OTHER transports but
768    /// SUPPRESSES same-transport (BLE->BLE) re-emit to avoid a broadcast loop
769    /// (ADR-059 echo-suppression). That suppression also blocks legitimate
770    /// multi-hop relay in an all-BLE topology (A -> B -> C): B applies A's
771    /// frame but never forwards it to C, so C can stay permanently stale.
772    /// We re-emit each freshly-ingested frame onto the BLE outbound queue
773    /// so B relays it to C. The dedup (bounded, TTL-swept) throttles
774    /// identical re-advertises so a relayed frame isn't re-broadcast in a
775    /// loop — a NEW value (different bytes) always relays immediately;
776    /// redundant re-adverts within the TTL are dropped. See
777    /// peat#978-adjacent relay gap.
778    #[cfg(all(feature = "sync", feature = "bluetooth"))]
779    relay_seen: std::sync::Mutex<std::collections::HashMap<u64, std::time::Instant>>,
780    /// Shared water-supply Counter (CRDT-over-Automerge-over-BLE).
781    /// Self-contained Automerge doc; its save() bytes ride the BLE frame
782    /// bus and merge natively.
783    #[cfg(feature = "sync")]
784    water_counter: water_counter::WaterCounter,
785    /// Generic CRDT KV documents (nodes/commands/cells/mission), Automerge over
786    /// the same crdt frame as the counter — mesh-wide convergence, no
787    /// lite-bridge.
788    #[cfg(feature = "sync")]
789    crdt_kv: crdt_kv::CrdtKvDocs,
790}
791
792#[cfg(feature = "sync")]
793#[uniffi::export]
794impl PeatNode {
795    // ── Shared water-supply Counter (CRDT-over-Automerge-over-BLE) ──────────
796    // The doc's save() bytes are carried over the BLE frame bus; merge is
797    // commutative/idempotent, so the caller can broadcast/relay freely.
798
799    // The Automerge doc bytes cross the FFI as a HEX string (the well-trodden
800    // String marshalling path; the doc is tiny so 2x size is irrelevant). The
801    // caller broadcasts the hex over the BLE bridge and feeds inbound hex to
802    // `crdt_counter_merge`.
803
804    /// Current merged value of the shared water-supply Counter.
805    pub fn crdt_counter_value(&self) -> i64 {
806        self.water_counter.value()
807    }
808
809    /// Apply `delta` liters to the shared Counter; returns the doc's save()
810    /// bytes (hex) for the caller to broadcast to peers.
811    pub fn crdt_counter_increment(&self, delta: i64) -> String {
812        hex::encode(self.water_counter.increment(delta))
813    }
814
815    /// Merge an inbound peer doc (hex of its save() bytes); returns the new
816    /// value. Safe with duplicate / stale / relayed / out-of-order input.
817    pub fn crdt_counter_merge(&self, hex_doc: String) -> i64 {
818        match hex::decode(hex_doc.trim()) {
819            Ok(bytes) => self.water_counter.merge(&bytes),
820            Err(_) => self.water_counter.value(),
821        }
822    }
823
824    /// Current save() bytes (hex), for periodic re-broadcast (catch-up).
825    pub fn crdt_counter_snapshot(&self) -> String {
826        hex::encode(self.water_counter.snapshot())
827    }
828
829    // ── Generic CRDT KV documents (nodes/commands/cells/mission) ────────────
830    // Records are key -> JSON-string in a per-collection Automerge doc; merge is
831    // set-union across keys (LWW per key). Same crdt-frame transport as the
832    // counter; doc bytes cross the FFI as hex.
833
834    /// Upsert `key = value_json` in `collection`; returns the doc's save()
835    /// bytes (hex) to broadcast.
836    pub fn crdt_kv_put(&self, collection: String, key: String, value_json: String) -> String {
837        hex::encode(self.crdt_kv.put(&collection, &key, &value_json))
838    }
839
840    /// All records in `collection` as a JSON object `{key: value}`.
841    pub fn crdt_kv_all(&self, collection: String) -> String {
842        self.crdt_kv.all_json(&collection)
843    }
844
845    /// Merge an inbound peer doc (hex) into `collection`.
846    pub fn crdt_kv_merge(&self, collection: String, hex_doc: String) {
847        if let Ok(bytes) = hex::decode(hex_doc.trim()) {
848            self.crdt_kv.merge(&collection, &bytes);
849        }
850    }
851
852    /// Current save() bytes (hex) of `collection`, for periodic re-broadcast.
853    pub fn crdt_kv_snapshot(&self, collection: String) -> String {
854        hex::encode(self.crdt_kv.snapshot(&collection))
855    }
856
857    /// Get this node's unique identifier (hex-encoded)
858    pub fn node_id(&self) -> String {
859        hex::encode(self.iroh_transport.endpoint_id().as_bytes())
860    }
861
862    /// Get this node's endpoint address for peer connections
863    pub fn endpoint_addr(&self) -> String {
864        format!("{:?}", self.iroh_transport.endpoint_addr())
865    }
866
867    /// Get the number of connected peers
868    pub fn peer_count(&self) -> u32 {
869        self.iroh_transport.peer_count() as u32
870    }
871
872    /// Get list of connected peer IDs
873    pub fn connected_peers(&self) -> Vec<String> {
874        self.iroh_transport
875            .connected_peers()
876            .iter()
877            .map(|id| hex::encode(id.as_bytes()))
878            .collect()
879    }
880
881    /// Return this node's iroh-endpoint first IP listening address
882    /// as an `"ip:port"` string, or `None` if no socket has been
883    /// bound yet.
884    ///
885    /// Intended for two-instance instrumented tests where two nodes
886    /// in the same process need to dial each other on loopback —
887    /// neither has the other's address from discovery, so the test
888    /// harness fetches it here and passes it to `connectPeerJni` on
889    /// the dialing side. peat-mesh#138 M4.
890    pub fn endpoint_socket_addr(&self) -> Option<String> {
891        self.iroh_transport.bound_socket_addr_string()
892    }
893
894    /// Start sync operations
895    ///
896    /// The authenticated accept loop (with formation handshake) is already
897    /// running from sync_backend.initialize() in create_node(). This method
898    /// starts the sync coordination layer: event-based and polling-based
899    /// sync handlers.
900    pub fn start_sync(&self) -> Result<(), PeatError> {
901        #[cfg(target_os = "android")]
902        android_log("start_sync: called");
903
904        // IMPORTANT: Use runtime.enter() to ensure tokio::spawn() inside start_sync()
905        // can find the runtime context. block_on() alone doesn't guarantee this on
906        // all platforms (especially Android where the JNI thread may not have proper
907        // thread-local storage for the Tokio runtime handle).
908        let _guard = self.runtime.enter();
909
910        #[cfg(target_os = "android")]
911        android_log("start_sync: runtime entered");
912
913        // Must run inside Tokio runtime because start_sync() calls tokio::spawn()
914        let result = self.runtime.block_on(async {
915            #[cfg(target_os = "android")]
916            android_log("start_sync: inside block_on");
917
918            // CRITICAL: Call start_sync() on the ACTUAL storage_backend instance,
919            // NOT on sync_backend.sync_engine() which returns a CLONED instance
920            // that doesn't have the transport event subscriptions set up!
921            //
922            // Note: The authenticated accept loop (with formation handshake and
923            // Connected event emission) is already running — it was started by
924            // sync_backend.initialize() in create_node(). The storage_backend's
925            // start_sync() will see the accept loop as already running and skip
926            // starting the plain (unauthenticated) accept loop.
927            self.storage_backend
928                .start_sync()
929                .map_err(|e| PeatError::SyncError { msg: e.to_string() })
930        });
931
932        #[cfg(target_os = "android")]
933        match &result {
934            Ok(_) => android_log("start_sync: SUCCESS - sync handlers spawned"),
935            Err(e) => android_log(&format!("start_sync: FAILED - {}", e)),
936        }
937
938        result
939    }
940
941    /// Stop sync operations
942    pub fn stop_sync(&self) -> Result<(), PeatError> {
943        // Must run inside Tokio runtime for consistency with start_sync()
944        self.runtime.block_on(async {
945            self.storage_backend
946                .stop_sync()
947                .map_err(|e| PeatError::SyncError { msg: e.to_string() })
948        })
949    }
950
951    /// Get sync statistics
952    pub fn sync_stats(&self) -> Result<SyncStats, PeatError> {
953        let stats = self
954            .storage_backend
955            .sync_stats()
956            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
957
958        Ok(SyncStats {
959            sync_active: stats.peer_count > 0, // Infer from peer count
960            connected_peers: self.iroh_transport.peer_count() as u32,
961            bytes_sent: stats.bytes_sent,
962            bytes_received: stats.bytes_received,
963        })
964    }
965
966    /// ADR-032 §Amendment A — unified per-peer transport state.
967    ///
968    /// Walks `TransportManager` for the given peer, calls
969    /// `peer_link_state` on each registered transport that can reach
970    /// it, and overlays the registered `TransportInstance.id` onto the
971    /// returned `LinkState.transport_id` (per the host-rendering rule:
972    /// the producer doesn't know its own registered id, the consumer
973    /// fills it). Returns `Ok(PeerTransportState { peer_id, links: vec![] })`
974    /// for peers no transport reports — "absence is a valid state."
975    ///
976    /// Hex-encoded `peer_id` matches the form `connected_peers()`
977    /// returns. Invalid hex is propagated as-is to peat-mesh's
978    /// `NodeId::new`, which is also a `String` wrapper — invalid input
979    /// surfaces as an empty `links` vec rather than an error, matching
980    /// the absence contract.
981    pub fn peer_transport_state(&self, peer_id: String) -> Result<PeerTransportState, PeatError> {
982        let mesh_peer = peat_mesh::NodeId::new(peer_id.clone());
983        let links = self
984            .transport_manager
985            .available_instances_for_peer(&mesh_peer)
986            .into_iter()
987            .filter_map(|transport_id| {
988                let transport = self.transport_manager.get_instance(&transport_id)?;
989                let mut state = transport.peer_link_state(&mesh_peer)?;
990                // Host-rendering rule: overlay the registered id onto
991                // the producer's placeholder. See
992                // `peat_mesh::transport::btle::BLE_TRANSPORT_ID_PLACEHOLDER`.
993                state.transport_id = transport_id;
994                Some(TransportLink::from(state))
995            })
996            .collect();
997        Ok(PeerTransportState { peer_id, links })
998    }
999
1000    /// ADR-032 §Amendment A — transport state for the peer set this
1001    /// `peat-ffi` instance currently enumerates from iroh.
1002    ///
1003    /// Designed for the plugin's periodic poll (~2 s) — the
1004    /// implementation walks transport state in a single pass without
1005    /// per-peer recursion.
1006    ///
1007    /// **Coverage caveat (Slice-4.d-interim — not the final SSOT
1008    /// shape).** This method enumerates peers exclusively from
1009    /// `self.iroh_transport.connected_peers()`. BLE-only peers
1010    /// (peers reachable via peat-btle but not currently visible to
1011    /// iroh) are **not** included. Plugin authors must continue to
1012    /// merge BLE-only peers from peat-btle's UniFFI surface
1013    /// directly until the single-source-of-truth migration
1014    /// completes. The Amendment A SSOT promise — "peat-ffi is the
1015    /// single source of truth, the plugin MUST NOT reach into
1016    /// peat-btle's UniFFI directly" — is the destination, not the
1017    /// current implementation; this method's coverage is a strict
1018    /// subset of that destination. Treat the cross-FFI peat-btle
1019    /// reach as a documented interim, not an idiom to standardize on.
1020    /// Tracked under defenseunicorns/peat#828.
1021    pub fn all_peer_transport_states(&self) -> Result<Vec<PeerTransportState>, PeatError> {
1022        // Collect a deduped peer set across registered transports.
1023        // peat-mesh's TransportManager doesn't expose a single
1024        // "all known peers" iterator, so we union over registered
1025        // instance peers via `iroh_transport.connected_peers()` for
1026        // the iroh side (the only transport peat-ffi currently
1027        // surfaces directly). BLE-side peers come through the
1028        // bluetooth feature's transport registration; their
1029        // connected_peers are surfaced through the same walk on
1030        // peer_transport_state once the caller knows their id from
1031        // the BLE-side UniFFI lookup. For now this method covers
1032        // peers visible to iroh; the plugin merges BLE-only peers
1033        // from its peat-btle UniFFI consumer separately while the
1034        // single-source-of-truth migration completes.
1035        let mut peer_ids: Vec<String> = self
1036            .iroh_transport
1037            .connected_peers()
1038            .iter()
1039            .map(|id| hex::encode(id.as_bytes()))
1040            .collect();
1041        peer_ids.sort();
1042        peer_ids.dedup();
1043
1044        let mut out = Vec::with_capacity(peer_ids.len());
1045        for peer_id in peer_ids {
1046            out.push(self.peer_transport_state(peer_id)?);
1047        }
1048        Ok(out)
1049    }
1050
1051    /// Request a full document sync with all connected peers.
1052    /// This pushes all local documents to each peer and pulls any documents
1053    /// they have. Useful for ensuring newly created documents propagate
1054    /// after the initial connection.
1055    pub fn request_sync(&self) -> Result<(), PeatError> {
1056        if let Some(coordinator) = self.storage_backend.sync_coordinator() {
1057            let peers = self.iroh_transport.connected_peers();
1058            let peer_count = peers.len();
1059            // Logcat-visible signal of every request_sync invocation:
1060            // peer count + each push's success/failure. peat-protocol's
1061            // internal `tracing::info!` doesn't reach logcat because no
1062            // tracing-subscriber is installed on Android, so the only
1063            // way to observe whether `sync_all_documents_with_peer`
1064            // actually ran is to surface it here at the FFI boundary
1065            // where `android_log` works.
1066            #[cfg(target_os = "android")]
1067            android_log(&format!(
1068                "request_sync: starting with {} connected peer(s)",
1069                peer_count
1070            ));
1071            let coord = Arc::clone(coordinator);
1072            self.runtime.block_on(async {
1073                for peer_id in peers {
1074                    match coord.sync_all_documents_with_peer(peer_id).await {
1075                        Ok(()) => {
1076                            #[cfg(target_os = "android")]
1077                            {
1078                                let peer_hex = hex::encode(peer_id.as_bytes());
1079                                android_log(&format!(
1080                                    "request_sync: pushed to peer {}",
1081                                    &peer_hex[..16]
1082                                ));
1083                            }
1084                        }
1085                        Err(_e) => {
1086                            #[cfg(target_os = "android")]
1087                            {
1088                                let peer_hex = hex::encode(peer_id.as_bytes());
1089                                android_log(&format!(
1090                                    "request_sync: FAILED for peer {}: {}",
1091                                    &peer_hex[..16],
1092                                    _e
1093                                ));
1094                            }
1095                        }
1096                    }
1097                }
1098            });
1099            #[cfg(target_os = "android")]
1100            android_log(&format!(
1101                "request_sync: complete ({} peer(s) attempted)",
1102                peer_count
1103            ));
1104        }
1105        Ok(())
1106    }
1107
1108    /// Connect to a peer node with formation handshake
1109    ///
1110    /// Establishes a QUIC connection, performs formation-key authentication,
1111    /// and emits a Connected event to trigger immediate sync handler spawning.
1112    pub fn connect_peer(&self, peer: PeerInfo) -> Result<(), PeatError> {
1113        let peat_peer = PeatPeerInfo {
1114            name: peer.name,
1115            node_id: peer.node_id,
1116            addresses: peer.addresses,
1117            relay_url: peer.relay_url,
1118        };
1119
1120        let _guard = self.runtime.enter();
1121
1122        self.runtime.block_on(async {
1123            let conn_opt = self
1124                .iroh_transport
1125                .connect_peer(&peat_peer)
1126                .await
1127                .map_err(|e| PeatError::ConnectionError { msg: e.to_string() })?;
1128
1129            // If we got a new connection, perform formation handshake and emit Connected
1130            if let Some(conn) = conn_opt {
1131                let peer_id = conn.remote_id();
1132
1133                if let Some(formation_key) = self.sync_backend.formation_key() {
1134                    use peat_protocol::network::perform_initiator_handshake;
1135                    match perform_initiator_handshake(&conn, &formation_key).await {
1136                        Ok(()) => {
1137                            // Emit Connected to trigger immediate sync handler spawning
1138                            self.iroh_transport.emit_peer_connected(peer_id);
1139
1140                            // Explicitly trigger document sync with the new peer.
1141                            // The event-based sync handler spawner should handle this,
1142                            // but we also trigger sync directly to ensure documents flow.
1143                            if let Some(coordinator) = self.storage_backend.sync_coordinator() {
1144                                let coord = Arc::clone(coordinator);
1145                                let sync_peer = peer_id;
1146                                tokio::spawn(async move {
1147                                    // Brief delay for connection to stabilize
1148                                    tokio::time::sleep(tokio::time::Duration::from_millis(500))
1149                                        .await;
1150                                    #[cfg(target_os = "android")]
1151                                    android_log(&format!(
1152                                        "Triggering sync_all_documents_with_peer for {:?}",
1153                                        sync_peer
1154                                    ));
1155                                    match coord.sync_all_documents_with_peer(sync_peer).await {
1156                                        Ok(()) => {
1157                                            #[cfg(target_os = "android")]
1158                                            android_log("sync_all_documents_with_peer: SUCCESS");
1159                                        }
1160                                        Err(e) => {
1161                                            #[cfg(target_os = "android")]
1162                                            android_log(&format!(
1163                                                "sync_all_documents_with_peer: FAILED - {}",
1164                                                e
1165                                            ));
1166                                        }
1167                                    }
1168                                });
1169                            }
1170                        }
1171                        Err(e) => {
1172                            conn.close(1u32.into(), b"authentication failed");
1173                            self.iroh_transport.disconnect(&peer_id).ok();
1174                            return Err(PeatError::ConnectionError {
1175                                msg: format!("Formation handshake failed: {}", e),
1176                            });
1177                        }
1178                    }
1179                } else {
1180                    // No formation key — emit Connected without handshake (backward compat)
1181                    self.iroh_transport.emit_peer_connected(peer_id);
1182                }
1183            }
1184            // If None, accept path is handling the connection
1185
1186            Ok(())
1187        })
1188    }
1189
1190    /// Disconnect from a peer by node ID
1191    ///
1192    /// Note: Currently disconnects matching peer from internal connection map.
1193    pub fn disconnect_peer(&self, node_id: &str) -> Result<(), PeatError> {
1194        // Find the matching endpoint ID from connected peers
1195        let connected = self.iroh_transport.connected_peers();
1196        for endpoint_id in connected {
1197            if hex::encode(endpoint_id.as_bytes()) == node_id {
1198                return self
1199                    .iroh_transport
1200                    .disconnect(&endpoint_id)
1201                    .map_err(|e| PeatError::ConnectionError { msg: e.to_string() });
1202            }
1203        }
1204
1205        Err(PeatError::ConnectionError {
1206            msg: format!("Peer {} not found in connected peers", node_id),
1207        })
1208    }
1209
1210    /// Store a JSON document in a collection
1211    pub fn put_document(
1212        &self,
1213        collection: &str,
1214        doc_id: &str,
1215        json_data: &str,
1216    ) -> Result<(), PeatError> {
1217        // Parse JSON to validate it
1218        let _: serde_json::Value =
1219            serde_json::from_str(json_data).map_err(|e| PeatError::InvalidInput {
1220                msg: format!("Invalid JSON: {}", e),
1221            })?;
1222
1223        self.runtime.block_on(async {
1224            let backend = &self.storage_backend;
1225            let coll = backend.collection(collection);
1226
1227            coll.upsert(doc_id, json_data.as_bytes().to_vec())
1228                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
1229        })
1230    }
1231
1232    /// Retrieve a document from the **raw-bytes store** as JSON.
1233    ///
1234    /// # Storage path
1235    ///
1236    /// This reads from `storage_backend.collection()` — the raw
1237    /// key-value store. It will NOT see documents that were:
1238    ///
1239    /// - Published via `publishDocumentJni` (which goes through
1240    ///   `peat_mesh::Node::publish`, the document layer)
1241    /// - Received from a peer via Automerge sync (which writes into the
1242    ///   document layer's CRDT, not the raw store)
1243    ///
1244    /// The JNI counterpart `getDocumentJni` deliberately uses
1245    /// `peat_mesh::Node::get()` instead so it round-trips with
1246    /// `publishDocumentJni`. If you're writing a new JNI method
1247    /// that reads documents published or synced via the document
1248    /// layer, follow `getDocumentJni`'s pattern, not this method's.
1249    pub fn get_document(
1250        &self,
1251        collection: &str,
1252        doc_id: &str,
1253    ) -> Result<Option<String>, PeatError> {
1254        self.runtime.block_on(async {
1255            let backend = &self.storage_backend;
1256            let coll = backend.collection(collection);
1257
1258            match coll.get(doc_id) {
1259                Ok(Some(bytes)) => {
1260                    let json = String::from_utf8(bytes).map_err(|e| PeatError::StorageError {
1261                        msg: format!("Invalid UTF-8: {}", e),
1262                    })?;
1263                    Ok(Some(json))
1264                }
1265                Ok(None) => Ok(None),
1266                Err(e) => Err(PeatError::StorageError { msg: e.to_string() }),
1267            }
1268        })
1269    }
1270
1271    /// Delete a document from a collection
1272    pub fn delete_document(&self, collection: &str, doc_id: &str) -> Result<(), PeatError> {
1273        self.runtime.block_on(async {
1274            let backend = &self.storage_backend;
1275            let coll = backend.collection(collection);
1276
1277            coll.delete(doc_id)
1278                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
1279        })
1280    }
1281
1282    /// List all document IDs in a collection
1283    pub fn list_documents(&self, collection: &str) -> Result<Vec<String>, PeatError> {
1284        self.runtime.block_on(async {
1285            let backend = &self.storage_backend;
1286            let coll = backend.collection(collection);
1287
1288            let docs = coll
1289                .scan()
1290                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
1291
1292            Ok(docs.into_iter().map(|(id, _)| id).collect())
1293        })
1294    }
1295
1296    /// Manually trigger sync for a specific document
1297    pub fn sync_document(&self, collection: &str, doc_id: &str) -> Result<(), PeatError> {
1298        let doc_key = format!("{}:{}", collection, doc_id);
1299
1300        self.runtime.block_on(async {
1301            let backend = &self.storage_backend;
1302
1303            backend
1304                .sync_document(&doc_key)
1305                .await
1306                .map_err(|e| PeatError::SyncError { msg: e.to_string() })
1307        })
1308    }
1309
1310    /// Subscribe to document changes
1311    ///
1312    /// Returns a SubscriptionHandle that must be kept alive to receive
1313    /// callbacks. When the handle is dropped or cancel() is called, the
1314    /// subscription stops.
1315    ///
1316    /// The callback will receive DocumentChange events for all documents.
1317    /// Filter by collection in your callback implementation if needed.
1318    ///
1319    /// Note: Only one subscription per node is supported. Calling subscribe
1320    /// again will fail if a subscription is already active.
1321    pub fn subscribe(
1322        &self,
1323        callback: Box<dyn DocumentCallback>,
1324    ) -> Result<Arc<SubscriptionHandle>, PeatError> {
1325        // Subscribe to ALL changes (local + peer-synced). Same origin-based dedup
1326        // as subscribe_poll: Remote events only fire the first time a doc_key is seen.
1327        let change_rx = self.store.subscribe_to_changes_with_origin();
1328
1329        // Create active flag for the subscription
1330        let active = Arc::new(AtomicBool::new(true));
1331        let active_clone = Arc::clone(&active);
1332        // Spawn a task to listen for changes and call the callback.
1333        // Dedup is handled at the Dart layer via content hashing — emit all
1334        // events here so cross-device updates are never silently dropped.
1335        let callback = Arc::new(callback);
1336        self.runtime.spawn(async move {
1337            let mut rx = change_rx;
1338
1339            while active_clone.load(Ordering::SeqCst) {
1340                tokio::select! {
1341                    result = rx.recv() => {
1342                        match result {
1343                            Ok(doc_change) => {
1344                                let doc_key = doc_change.key;
1345                                // Parse the document key (format: "collection:doc_id")
1346                                let change = if let Some((collection, doc_id)) = doc_key.split_once(':') {
1347                                    DocumentChange {
1348                                        collection: collection.to_string(),
1349                                        doc_id: doc_id.to_string(),
1350                                        change_type: ChangeType::Upsert,
1351                                    }
1352                                } else {
1353                                    DocumentChange {
1354                                        collection: "default".to_string(),
1355                                        doc_id: doc_key,
1356                                        change_type: ChangeType::Upsert,
1357                                    }
1358                                };
1359
1360                                callback.on_change(change);
1361                            }
1362                            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1363                                // Some messages were skipped due to slow receiver
1364                                callback.on_error(format!("Lagged {} messages", n));
1365                            }
1366                            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1367                                // Channel closed
1368                                callback.on_error("Document change channel closed".to_string());
1369                                break;
1370                            }
1371                        }
1372                    }
1373                    _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
1374                        // Periodic check if we should stop
1375                        if !active_clone.load(Ordering::SeqCst) {
1376                            break;
1377                        }
1378                    }
1379                }
1380            }
1381        });
1382
1383        Ok(Arc::new(SubscriptionHandle::new(active)))
1384    }
1385
1386    /// Subscribe to document changes using a poll-based model.
1387    ///
1388    /// Returns a [`SubscriptionHandle`] whose
1389    /// [`SubscriptionHandle::poll_changes`] method drains buffered
1390    /// [`DocumentChange`] events. Callers drive delivery by periodically
1391    /// calling `poll_changes` (e.g. from a Dart isolate loop or
1392    /// `Timer.periodic`) — no foreign callback interface is required.
1393    ///
1394    /// Drop or call [`SubscriptionHandle::cancel`] on the handle to stop.
1395    ///
1396    /// # Broadcast lag
1397    ///
1398    /// The underlying channel has a bounded capacity. If `poll_changes` is not
1399    /// called frequently enough relative to the document-change rate, the
1400    /// broadcast channel will lag and silently drop events — `poll_changes`
1401    /// returns a partial set with no indication that events were missed.
1402    /// Callers should treat a long gap between `poll_changes` calls (e.g. the
1403    /// app was backgrounded) as a signal to trigger a full collection resync
1404    /// rather than relying on the change stream alone.
1405    pub fn subscribe_poll(&self) -> Result<Arc<SubscriptionHandle>, PeatError> {
1406        // Subscribe to ALL changes (local + peer-synced) via the origin-tagged channel.
1407        //
1408        // The gossip channel fires on every Automerge sync protocol exchange, including
1409        // redundant re-syncs of unchanged documents. To prevent a sync loop (periodic
1410        // requestSync re-fires Remote events for every already-known doc), we apply
1411        // origin-based deduplication:
1412        // Emit all events — dedup is handled in the Dart layer via content
1413        // hashing so cross-device updates (including repeated increments)
1414        // are never silently dropped by the Rust subscription.
1415        let change_rx = self.store.subscribe_to_changes_with_origin();
1416        let active = Arc::new(AtomicBool::new(true));
1417        let active_clone = Arc::clone(&active);
1418        let pending = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
1419            DocumentChange,
1420        >::new()));
1421        let pending_clone = Arc::clone(&pending);
1422
1423        self.runtime.spawn(async move {
1424            let mut rx = change_rx;
1425            while active_clone.load(Ordering::SeqCst) {
1426                tokio::select! {
1427                    result = rx.recv() => {
1428                        match result {
1429                            Ok(doc_change) => {
1430                                let doc_key = doc_change.key;
1431                                let change = if let Some((collection, doc_id)) = doc_key.split_once(':') {
1432                                    DocumentChange {
1433                                        collection: collection.to_string(),
1434                                        doc_id: doc_id.to_string(),
1435                                        change_type: ChangeType::Upsert,
1436                                    }
1437                                } else {
1438                                    DocumentChange {
1439                                        collection: "default".to_string(),
1440                                        doc_id: doc_key,
1441                                        change_type: ChangeType::Upsert,
1442                                    }
1443                                };
1444                                if let Ok(mut q) = pending_clone.lock() {
1445                                    q.push_back(change);
1446                                }
1447                            }
1448                            Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1449                            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1450                        }
1451                    }
1452                    _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
1453                        if !active_clone.load(Ordering::SeqCst) {
1454                            break;
1455                        }
1456                    }
1457                }
1458            }
1459        });
1460
1461        Ok(Arc::new(SubscriptionHandle::new_with_queue(
1462            active, pending,
1463        )))
1464    }
1465}
1466
1467/// Create a new PeatNode with FormationKey authentication
1468///
1469/// Requires `app_id` and `shared_key` for peer authentication.
1470/// Only peers with matching credentials can connect and sync.
1471///
1472/// # Arguments
1473///
1474/// * `config` - Node configuration including:
1475///   - `app_id`: Formation/application identifier (use same value for all nodes
1476///     in your swarm)
1477///   - `shared_key`: Base64-encoded 32-byte secret key (generate with `openssl
1478///     rand -base64 32`)
1479///   - `bind_address`: Optional address to bind (default: "0.0.0.0:0")
1480///   - `storage_path`: Directory for persistent storage
1481///
1482/// Note: This function is NOT async because we manage our own Tokio runtime
1483/// to ensure proper context for Iroh transport operations.
1484#[cfg(feature = "sync")]
1485#[uniffi::export]
1486pub fn create_node(config: NodeConfig) -> Result<Arc<PeatNode>, PeatError> {
1487    use std::time::Instant;
1488    let total_start = Instant::now();
1489
1490    // Validate credentials
1491    if config.app_id.is_empty() {
1492        return Err(PeatError::InvalidInput {
1493            msg: "app_id cannot be empty".to_string(),
1494        });
1495    }
1496    if config.shared_key.is_empty() {
1497        return Err(PeatError::InvalidInput {
1498            msg: "shared_key cannot be empty".to_string(),
1499        });
1500    }
1501
1502    // Helper: read RSS from /proc/self/status
1503    fn get_rss_kb() -> u64 {
1504        std::fs::read_to_string("/proc/self/status")
1505            .ok()
1506            .and_then(|s| {
1507                s.lines()
1508                    .find(|l| l.starts_with("VmRSS:"))
1509                    .and_then(|l| l.split_whitespace().nth(1))
1510                    .and_then(|v| v.parse().ok())
1511            })
1512            .unwrap_or(0)
1513    }
1514
1515    #[cfg(target_os = "android")]
1516    android_log(&format!("[MEM] Before runtime: {} kB", get_rss_kb()));
1517
1518    // TIMING: Create runtime
1519    let phase_start = Instant::now();
1520
1521    // Create a dedicated Tokio runtime for this node
1522    // Use 4 worker threads to avoid starving BLE D-Bus tasks when Iroh
1523    // background tasks (discovery, relay, pkarr) are running concurrently.
1524    let runtime = tokio::runtime::Builder::new_multi_thread()
1525        .worker_threads(4)
1526        .enable_all()
1527        .build()
1528        .map_err(|e| PeatError::SyncError {
1529            msg: format!("Failed to create runtime: {}", e),
1530        })?;
1531
1532    let runtime_ms = phase_start.elapsed().as_millis();
1533    #[cfg(target_os = "android")]
1534    android_log(&format!("[TIMING] Runtime creation: {}ms", runtime_ms));
1535    #[cfg(target_os = "android")]
1536    android_log(&format!("[MEM] After runtime: {} kB", get_rss_kb()));
1537    #[cfg(not(target_os = "android"))]
1538    eprintln!("[Peat TIMING] Runtime creation: {}ms", runtime_ms);
1539
1540    // Parse bind address
1541    let bind_addr: SocketAddr = config
1542        .bind_address
1543        .as_deref()
1544        .unwrap_or("0.0.0.0:0")
1545        .parse()
1546        .map_err(|e| PeatError::InvalidInput {
1547            msg: format!("Invalid bind address: {}", e),
1548        })?;
1549
1550    // Create storage path
1551    let storage_path = PathBuf::from(&config.storage_path);
1552    std::fs::create_dir_all(&storage_path).map_err(|e| PeatError::StorageError {
1553        msg: format!("Failed to create storage directory: {}", e),
1554    })?;
1555
1556    // TIMING: Parallel store + transport initialization
1557    let phase_start = Instant::now();
1558
1559    // OPTIMIZATION: Run store opening and transport creation in parallel
1560    // These are independent operations that can overlap to reduce startup time.
1561    // - AutomergeStore::open() is blocking I/O (redb database)
1562    // - IrohTransport creation is async (QUIC endpoint binding)
1563    //
1564    // OPTIMIZATION: Use fast constructor WITHOUT mDNS discovery for faster startup.
1565    // mDNS discovery is deferred until after the sync backend is initialized.
1566    // This reduces "startup intensity" that was causing Docker API timeouts
1567    // in large-scale deployments (see 384-node hierarchical simulations).
1568    let seed = format!("{}/{}", config.app_id, config.storage_path);
1569    let storage_path_for_store = storage_path.clone();
1570
1571    let (store, transport, store_ms, transport_ms) = runtime.block_on(async {
1572        let store_start = Instant::now();
1573        let transport_start = Instant::now();
1574
1575        // Spawn store opening on blocking thread pool (it does sync I/O).
1576        // Retry up to 10 times with 200 ms delays — the previous node's redb
1577        // file lock may not be released immediately when the user stops and
1578        // immediately restarts the node (background Arcs are still alive).
1579        let store_handle = tokio::task::spawn_blocking(move || {
1580            let mut last_err = None;
1581            // Retry for up to ~30 s — iOS background tasks can hold the redb
1582            // lock for longer than macOS before their Arcs are fully released.
1583            for _ in 0..60u32 {
1584                match AutomergeStore::open(&storage_path_for_store) {
1585                    Ok(s) => return (Ok(s), store_start.elapsed().as_millis()),
1586                    Err(e) => {
1587                        last_err = Some(e);
1588                        std::thread::sleep(std::time::Duration::from_millis(500));
1589                    }
1590                }
1591            }
1592            (Err(last_err.unwrap()), store_start.elapsed().as_millis())
1593        });
1594
1595        // Create transport WITH mDNS discovery wired into the endpoint
1596        let transport_future = async {
1597            let result = IrohTransport::from_seed_with_discovery_at_addr(&seed, bind_addr).await;
1598            (result, transport_start.elapsed().as_millis())
1599        };
1600
1601        // Wait for both to complete
1602        let (store_result, transport_result) = tokio::join!(store_handle, transport_future);
1603
1604        // Unwrap the JoinHandle result first, then the actual result
1605        let (store_inner, store_elapsed) = store_result.map_err(|e| PeatError::StorageError {
1606            msg: format!("Store task panicked: {}", e),
1607        })?;
1608        let store = store_inner.map_err(|e| PeatError::StorageError {
1609            msg: format!("Failed to open store: {}", e),
1610        })?;
1611
1612        #[cfg(target_os = "android")]
1613        android_log(&format!(
1614            "[MEM] After store open: {} kB (store {}ms)",
1615            get_rss_kb(),
1616            store_elapsed
1617        ));
1618
1619        let (transport_inner, transport_elapsed) = transport_result;
1620        let transport = transport_inner.map_err(|e| PeatError::ConnectionError {
1621            msg: format!("Failed to create transport with mDNS: {}", e),
1622        })?;
1623
1624        #[cfg(target_os = "android")]
1625        android_log(&format!(
1626            "[MEM] After iroh transport: {} kB (transport {}ms)",
1627            get_rss_kb(),
1628            transport_elapsed
1629        ));
1630
1631        Ok::<_, PeatError>((
1632            Arc::new(store),
1633            Arc::new(transport),
1634            store_elapsed,
1635            transport_elapsed,
1636        ))
1637    })?;
1638
1639    let parallel_total_ms = phase_start.elapsed().as_millis();
1640    #[cfg(target_os = "android")]
1641    {
1642        android_log(&format!("[TIMING] Store open: {}ms", store_ms));
1643        android_log(&format!(
1644            "[TIMING] Transport create (with mDNS): {}ms",
1645            transport_ms
1646        ));
1647        android_log(&format!(
1648            "[TIMING] Parallel total (max of above): {}ms",
1649            parallel_total_ms
1650        ));
1651    }
1652    #[cfg(not(target_os = "android"))]
1653    {
1654        eprintln!("[Peat TIMING] Store open: {}ms", store_ms);
1655        eprintln!(
1656            "[Peat TIMING] Transport create (with mDNS): {}ms",
1657            transport_ms
1658        );
1659        eprintln!(
1660            "[Peat TIMING] Parallel total (max of above): {}ms",
1661            parallel_total_ms
1662        );
1663    }
1664
1665    // Create storage backend with transport
1666    let storage_backend = Arc::new(AutomergeBackend::with_transport(
1667        Arc::clone(&store),
1668        Arc::clone(&transport),
1669    ));
1670
1671    // Create sync backend (AutomergeIrohBackend) for authenticated P2P sync
1672    // Note: AutomergeIrohBackend wraps storage::AutomergeBackend for the
1673    // DataSyncBackend trait
1674    let sync_backend = Arc::new(AutomergeIrohBackend::new(
1675        Arc::clone(&storage_backend),
1676        Arc::clone(&transport),
1677    ));
1678
1679    // IMPORTANT (Issue #275): Subscribe to peer events BEFORE initializing sync
1680    // backend. The initialize() call spawns the accept loop, so we need to
1681    // subscribe first to catch all connection events including the initial
1682    // ones.
1683    let mut event_rx = transport.subscribe_peer_events();
1684
1685    // TIMING: Sync backend initialization
1686    let phase_start = Instant::now();
1687
1688    // Initialize sync backend with credentials for FormationKey authentication
1689    let backend_config = BackendConfig {
1690        app_id: config.app_id.clone(),
1691        persistence_dir: storage_path.clone(),
1692        shared_key: Some(config.shared_key.clone()),
1693        transport: TransportConfig::default(),
1694        extra: std::collections::HashMap::new(),
1695    };
1696
1697    runtime.block_on(async {
1698        sync_backend
1699            .initialize(backend_config)
1700            .await
1701            .map_err(|e| PeatError::SyncError {
1702                msg: format!("Failed to initialize sync backend: {}", e),
1703            })
1704    })?;
1705
1706    let sync_init_ms = phase_start.elapsed().as_millis();
1707    #[cfg(target_os = "android")]
1708    {
1709        android_log(&format!("[TIMING] Sync backend init: {}ms", sync_init_ms));
1710        android_log("=== sync_backend.initialize() completed successfully ===");
1711    }
1712    #[cfg(not(target_os = "android"))]
1713    eprintln!("[Peat TIMING] Sync backend init: {}ms", sync_init_ms);
1714
1715    // Start background task to listen for peer events and forward to Java (Issue
1716    // #275)
1717    let cleanup_running = Arc::new(AtomicBool::new(true));
1718    let cleanup_flag = Arc::clone(&cleanup_running);
1719    let runtime_arc = Arc::new(runtime);
1720
1721    // Clone transport for the cleanup task
1722    let transport_for_cleanup = Arc::clone(&transport);
1723
1724    // Log that we're starting the peer event listener
1725    #[cfg(target_os = "android")]
1726    android_log("Starting peer event listener task (Issue #275)");
1727
1728    runtime_arc.spawn(async move {
1729        #[cfg(target_os = "android")]
1730        android_log("Peer event listener task running");
1731
1732        while cleanup_flag.load(Ordering::Relaxed) {
1733            tokio::select! {
1734                event_result = event_rx.recv() => {
1735                    match event_result {
1736                        Some(event) => {
1737                            #[cfg(target_os = "android")]
1738                            android_log(&format!("Received transport peer event: {:?}", event));
1739
1740                            match event {
1741                                TransportPeerEvent::Connected { endpoint_id, .. } => {
1742                                    let peer_id = hex::encode(endpoint_id.as_bytes());
1743                                    #[cfg(target_os = "android")]
1744                                    android_log(&format!("Processing Connected event for peer: {}", peer_id));
1745                                    notify_peer_connected(&peer_id);
1746                                }
1747                                TransportPeerEvent::Disconnected { endpoint_id, reason } => {
1748                                    let peer_id = hex::encode(endpoint_id.as_bytes());
1749                                    #[cfg(target_os = "android")]
1750                                    android_log(&format!("Processing Disconnected event for peer: {} reason: {}", peer_id, reason));
1751                                    notify_peer_disconnected(&peer_id, &reason);
1752                                }
1753                            }
1754                        }
1755                        None => {
1756                            #[cfg(target_os = "android")]
1757                            android_log("Event channel closed, exiting peer event listener");
1758                            break;
1759                        }
1760                    }
1761                }
1762                _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
1763                    // Periodically call peer_count() to trigger cleanup_closed_connections()
1764                    // This detects dead connections and emits Disconnected events
1765                    let count = transport_for_cleanup.peer_count();
1766                    #[cfg(target_os = "android")]
1767                    android_log(&format!("Periodic cleanup tick - peer count: {}", count));
1768                }
1769            }
1770        }
1771
1772        #[cfg(target_os = "android")]
1773        android_log("Peer event listener task exiting");
1774    });
1775
1776    // IMPORTANT (Issue #378): Use the storage_backend from sync_backend, NOT a new
1777    // one! Creating a separate AutomergeBackend would cause sync coordinator
1778    // state to be split, resulting in data not being received from peers.
1779    let storage_backend = sync_backend.storage_backend();
1780
1781    // Create TransportManager for multi-transport coordination (ADR-032, #555)
1782    // Build TransportManagerConfig from FFI config (PACE policy + collection
1783    // routes)
1784    let mut tm_config = TransportManagerConfig::default();
1785
1786    if let Some(ref transport_config) = config.transport {
1787        // Build PACE policy from transport_preference
1788        if let Some(ref prefs) = transport_config.transport_preference {
1789            let policy = TransportPolicy::new("ffi-config").primary(prefs.clone());
1790            tm_config.default_policy = Some(policy);
1791        }
1792
1793        // Parse collection routes from JSON
1794        if let Some(ref routes_json) = transport_config.collection_routes_json {
1795            match serde_json::from_str::<CollectionRouteTable>(routes_json) {
1796                Ok(table) => {
1797                    tm_config.collection_routes = table;
1798                }
1799                Err(e) => {
1800                    eprintln!("[Peat] Failed to parse collection_routes_json: {}", e);
1801                }
1802            }
1803        }
1804    }
1805
1806    let mut transport_manager = TransportManager::new(tm_config);
1807
1808    // Create IrohMeshTransport wrapper and register with TransportManager.
1809    // This allows the transport to be selected via PACE policy alongside
1810    // future transports.
1811    //
1812    // ADR-062 Phase 2 (peat#926): peat-mesh's IrohMeshTransport takes
1813    // `Vec<PeerInfo>` directly instead of `Arc<RwLock<PeerConfig>>` — the
1814    // `formation` and `local` fields of PeerConfig were never used by the
1815    // transport itself; they remain in peat-protocol's security layer.
1816    // peat-ffi starts with an empty static-peer list; runtime peer
1817    // additions go through `iroh_mesh_transport.set_static_peers(...)`.
1818    let iroh_mesh_transport = Arc::new(IrohMeshTransport::new(Arc::clone(&transport), Vec::new()));
1819    let iroh_as_transport: Arc<dyn Transport> = iroh_mesh_transport.clone();
1820    transport_manager.register(iroh_as_transport.clone());
1821
1822    // Register as PACE instance for collection routing
1823    let iroh_instance = TransportInstance::new(
1824        "iroh-primary",
1825        TransportType::Quic,
1826        TransportCapabilities::quic(),
1827    )
1828    .with_description("Primary Iroh/QUIC transport");
1829    transport_manager.register_instance(iroh_instance, iroh_as_transport);
1830
1831    // Initialize BLE transport if enabled (ADR-039, #556)
1832    #[cfg(feature = "bluetooth")]
1833    if let Some(ref transport_config) = config.transport {
1834        if transport_config.enable_ble {
1835            #[cfg(target_os = "android")]
1836            {
1837                use peat_btle::platform::android::AndroidAdapter;
1838                use peat_btle::{BleConfig, BluetoothLETransport};
1839
1840                android_log("BLE transport requested - initializing AndroidAdapter stub");
1841
1842                // Derive BLE node ID from Iroh endpoint key (same as Linux path)
1843                let iroh_endpoint_id = transport.endpoint_id();
1844                let iroh_key_bytes = iroh_endpoint_id.as_bytes();
1845                let ble_node_id = peat_btle::NodeId::new(u32::from_be_bytes([
1846                    iroh_key_bytes[28],
1847                    iroh_key_bytes[29],
1848                    iroh_key_bytes[30],
1849                    iroh_key_bytes[31],
1850                ]));
1851                let ble_config = BleConfig::new(ble_node_id);
1852                let adapter = AndroidAdapter::new_stub();
1853                let btle = BluetoothLETransport::new(ble_config, adapter);
1854                let ble_transport = Arc::new(PeatBleTransport::new(btle));
1855                let ble_as_transport: Arc<dyn Transport> = ble_transport.clone();
1856                transport_manager.register(ble_as_transport.clone());
1857
1858                // Register as PACE instance for collection routing
1859                let ble_instance = TransportInstance::new(
1860                    "ble-primary",
1861                    TransportType::BluetoothLE,
1862                    TransportCapabilities::bluetooth_le(),
1863                )
1864                .with_description("Primary BLE transport (Android)");
1865                transport_manager.register_instance(ble_instance, ble_as_transport);
1866
1867                // Store in global for JNI access
1868                *ANDROID_BLE_TRANSPORT.lock().unwrap() = Some(ble_transport);
1869
1870                android_log("BLE transport registered as PACE instance 'ble-primary'");
1871            }
1872
1873            #[cfg(not(target_os = "android"))]
1874            {
1875                // On non-Android platforms, we can initialize BLE directly
1876                // Linux uses BluerAdapter, macOS uses CoreBluetoothAdapter
1877                #[cfg(target_os = "linux")]
1878                {
1879                    use peat_btle::platform::linux::BluerAdapter;
1880                    use peat_btle::{BleAdapter, BleConfig, BluetoothLETransport, PowerProfile};
1881
1882                    // Parse power profile from config
1883                    let power_profile = match transport_config.ble_power_profile.as_deref() {
1884                        Some("aggressive") => PowerProfile::Aggressive,
1885                        Some("low_power") => PowerProfile::LowPower,
1886                        _ => PowerProfile::Balanced,
1887                    };
1888
1889                    // Derive a 32-bit BLE node ID from the Iroh endpoint's public key
1890                    // Use last 4 bytes of the 32-byte key for a unique-enough identifier
1891                    let iroh_endpoint_id = transport.endpoint_id();
1892                    let iroh_key_bytes = iroh_endpoint_id.as_bytes();
1893                    let ble_node_id = peat_btle::NodeId::new(u32::from_be_bytes([
1894                        iroh_key_bytes[28],
1895                        iroh_key_bytes[29],
1896                        iroh_key_bytes[30],
1897                        iroh_key_bytes[31],
1898                    ]));
1899
1900                    // Create BLE config with node ID, power profile, and mesh ID
1901                    let mut ble_config = BleConfig::new(ble_node_id);
1902                    ble_config.power_profile = power_profile;
1903                    if let Some(ref mesh_id) = transport_config.ble_mesh_id {
1904                        ble_config.mesh.mesh_id = mesh_id.clone();
1905                    }
1906
1907                    // Create BLE transport with BluerAdapter
1908                    // IMPORTANT: All async BLE operations (create adapter, init, register
1909                    // GATT, start advertising/scanning) MUST happen in a single block_on().
1910                    // Splitting into two block_on() calls suspends the tokio runtime between
1911                    // them, which can cause the GATT ApplicationHandle's D-Bus registration
1912                    // to be dropped before advertising starts — making the GATT service
1913                    // intermittently invisible to remote devices.
1914                    //
1915                    // Brings `MeshTransport` into scope so `ble_transport.start()` resolves;
1916                    // mirrors the import at the other start() call site (line ~3259).
1917                    use peat_protocol::transport::MeshTransport;
1918                    match runtime_arc.block_on(async {
1919                        let mut adapter = BluerAdapter::new().await?;
1920
1921                        // Initialize adapter with config (stores node ID, mesh ID, etc.)
1922                        adapter.init(&ble_config).await?;
1923
1924                        // Register GATT service with BlueZ so peers can connect
1925                        adapter.register_gatt_service().await?;
1926
1927                        // Wrap in transport layers
1928                        let btle = BluetoothLETransport::new(ble_config, adapter);
1929                        let ble_transport = Arc::new(PeatBleTransport::new(btle));
1930
1931                        // Start advertising and scanning in the same async context
1932                        ble_transport.start().await.map_err(|e| {
1933                            peat_btle::BleError::PlatformError(format!(
1934                                "Failed to start BLE transport: {}",
1935                                e
1936                            ))
1937                        })?;
1938
1939                        Ok::<_, peat_btle::BleError>(ble_transport)
1940                    }) {
1941                        Ok(ble_transport) => {
1942                            let ble_as_transport: Arc<dyn Transport> = ble_transport.clone();
1943                            transport_manager.register(ble_as_transport.clone());
1944
1945                            // Register as PACE instance for collection routing
1946                            let ble_instance = TransportInstance::new(
1947                                "ble-primary",
1948                                TransportType::BluetoothLE,
1949                                TransportCapabilities::bluetooth_le(),
1950                            )
1951                            .with_description("Primary BLE transport");
1952                            transport_manager.register_instance(ble_instance, ble_as_transport);
1953                            eprintln!(
1954                                "[Peat] BLE transport registered as PACE instance 'ble-primary'"
1955                            );
1956                        }
1957                        Err(e) => {
1958                            eprintln!("[Peat] Failed to initialize BLE adapter: {} (continuing without BLE)", e);
1959                        }
1960                    }
1961                }
1962
1963                #[cfg(not(target_os = "linux"))]
1964                eprintln!(
1965                    "[Peat] BLE transport requested but not yet implemented for this platform"
1966                );
1967            }
1968        }
1969    }
1970
1971    // TIMING: Total startup time
1972    let total_ms = total_start.elapsed().as_millis();
1973    #[cfg(target_os = "android")]
1974    android_log(&format!(
1975        "[TIMING] === TOTAL create_node: {}ms ===",
1976        total_ms
1977    ));
1978    #[cfg(not(target_os = "android"))]
1979    eprintln!("[Peat TIMING] === TOTAL create_node: {}ms ===", total_ms);
1980
1981    // Compose `peat_mesh::Node` over the same `AutomergeIrohBackend` the
1982    // existing typed surface uses. Both layers see the same underlying
1983    // doc store; the Node adds a generic publish/observe surface for
1984    // doc-type-agnostic callers (the `ingest*Jni` family, future
1985    // per-doc-type typed wrappers).
1986    #[cfg(feature = "sync")]
1987    let node = {
1988        use peat_mesh::sync::traits::DataSyncBackend;
1989        let backend_dyn: Arc<dyn DataSyncBackend> = sync_backend.clone();
1990        Arc::new(peat_mesh::Node::new(backend_dyn))
1991    };
1992
1993    // BleTranslator: BLE-typed structs ↔ Automerge documents (ADR-041).
1994    // Built only when the bluetooth feature is enabled. Used by the
1995    // `ingest*Jni` family of methods + (Slice 1.b.2.2) the
1996    // `OutboundFrameCallback` JNI surface.
1997    #[cfg(all(feature = "sync", feature = "bluetooth"))]
1998    let ble_translator = {
1999        use peat_protocol::sync::ble_translation::BleTranslator;
2000        Arc::new(BleTranslator::with_defaults())
2001    };
2002
2003    let node_arc = Arc::new(PeatNode {
2004        sync_backend,
2005        storage_backend,
2006        #[cfg(feature = "sync")]
2007        node,
2008        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2009        ble_translator,
2010        transport_manager,
2011        iroh_transport: transport,
2012        store,
2013        #[cfg(feature = "sync")]
2014        water_counter: water_counter::WaterCounter::load_or_init(
2015            storage_path.join("water.automerge"),
2016        ),
2017        #[cfg(feature = "sync")]
2018        crdt_kv: crdt_kv::CrdtKvDocs::new(storage_path.clone()),
2019        storage_path,
2020        runtime: runtime_arc,
2021        cleanup_running,
2022        #[cfg(feature = "sync")]
2023        blob_store: std::sync::RwLock::new(None),
2024        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2025        outbound_queue: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
2026        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2027        outbound_fanout: std::sync::Mutex::new(None),
2028        #[cfg(all(feature = "sync", feature = "bluetooth"))]
2029        relay_seen: std::sync::Mutex::new(std::collections::HashMap::new()),
2030    });
2031
2032    // Publish an OWNING reference to the JNI-visible global so a Kotlin bridge
2033    // (e.g. the BLE pipe) can reach a node created via the Dart/UniFFI path
2034    // without risking use-after-free: the prior code stashed a non-owning
2035    // alias whose sole owner was the Dart handle, so Dart's GC finalizer could
2036    // free the node out from under a `getGlobalNodeHandleJni` consumer.
2037    //
2038    // Android-only: the global is consumed solely by the JNI bridges (BLE /
2039    // Wi-Fi Direct). iOS reaches BLE via the independent UniFFI poll bridge and
2040    // never reads it, so storing an owning Arc there would only leak — the
2041    // node's sole owner on iOS must be the Dart UniFFI handle so `close()`/
2042    // dispose actually drops it and releases the redb file lock. Without this
2043    // gate, an in-app Stop on iOS left the node (and its redb store) alive, so
2044    // the next Start hit "Failed to open redb database" on the still-locked
2045    // file. iOS has no `clearGlobalNodeHandleJni` counterpart to release it.
2046    #[cfg(target_os = "android")]
2047    set_global_node_handle(&node_arc);
2048    Ok(node_arc)
2049}
2050
2051// Add new error variants for sync operations
2052#[cfg(feature = "sync")]
2053impl From<anyhow::Error> for PeatError {
2054    fn from(e: anyhow::Error) -> Self {
2055        PeatError::SyncError { msg: e.to_string() }
2056    }
2057}
2058
2059// =============================================================================
2060// Peat Data Types for Consumer Integration
2061// =============================================================================
2062//
2063// These types represent Peat entities that can be synced and displayed by
2064// consumer plugins. They use well-known collection names for document storage.
2065
2066/// Well-known collection names for Peat data
2067pub mod collections {
2068    /// Collection for Peat cells (teams/squads)
2069    pub const CELLS: &str = "cells";
2070    /// Collection for detected tracks (entities being tracked)
2071    pub const TRACKS: &str = "tracks";
2072    /// Collection for nodes (robots, drones, sensors)
2073    pub const NODES: &str = "nodes";
2074    /// Collection for capability advertisements
2075    pub const CAPABILITIES: &str = "capabilities";
2076    /// Collection for commands (C2 messages)
2077    pub const COMMANDS: &str = "commands";
2078    /// Collection for operator-placed map markers (CoT pins synced
2079    /// across the mesh via the universal-Document transport,
2080    /// ADR-035). Receiver renders consistently regardless of which
2081    /// peer originated the marker — the doc store is the source of
2082    /// truth, transport is invisible to consumers.
2083    pub const MARKERS: &str = "markers";
2084}
2085
2086/// CoT 2525 placeholder type that
2087/// [`parse_marker_publish_json`] substitutes when a tombstone body
2088/// arrives without an explicit `type` field. Tombstones intentionally
2089/// omit geo + type to keep the BLE frame tight (~40 bytes vs ~120
2090/// for a full marker); receivers filter `_deleted: true` entries out
2091/// of "current markers" views before the placeholder is rendered, so
2092/// the value never reaches a UI. Lifted to a named constant so a
2093/// future change to the placeholder shape (e.g., shifting to a
2094/// neutral "unknown" or an empty string) lands in one place rather
2095/// than being scattered through the parser.
2096const TOMBSTONE_PLACEHOLDER_TYPE: &str = "a-u-G";
2097
2098/// Cell status enumeration
2099#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2100pub enum CellStatus {
2101    /// Cell is active and operational
2102    Active,
2103    /// Cell is forming (members joining)
2104    Forming,
2105    /// Cell has degraded capability
2106    Degraded,
2107    /// Cell is offline
2108    Offline,
2109}
2110
2111impl CellStatus {
2112    fn from_str(s: &str) -> Self {
2113        match s.to_uppercase().as_str() {
2114            "ACTIVE" => Self::Active,
2115            "FORMING" => Self::Forming,
2116            "DEGRADED" => Self::Degraded,
2117            "OFFLINE" => Self::Offline,
2118            _ => Self::Offline,
2119        }
2120    }
2121
2122    fn as_str(&self) -> &'static str {
2123        match self {
2124            Self::Active => "ACTIVE",
2125            Self::Forming => "FORMING",
2126            Self::Degraded => "DEGRADED",
2127            Self::Offline => "OFFLINE",
2128        }
2129    }
2130}
2131
2132/// Peat Cell information for display
2133#[derive(Debug, Clone, uniffi::Record)]
2134pub struct CellInfo {
2135    /// Unique cell identifier
2136    pub id: String,
2137    /// Human-readable cell name (e.g., "Alpha Team")
2138    pub name: String,
2139    /// Cell status
2140    pub status: CellStatus,
2141    /// Number of nodes in this cell
2142    pub node_count: u32,
2143    /// Center latitude (WGS84)
2144    pub center_lat: f64,
2145    /// Center longitude (WGS84)
2146    pub center_lon: f64,
2147    /// List of capabilities (e.g., ["OBJECT_TRACKING", "COMMUNICATION"])
2148    pub capabilities: Vec<String>,
2149    /// Parent formation ID (if any)
2150    pub formation_id: Option<String>,
2151    /// Cell leader node ID (if any)
2152    pub leader_id: Option<String>,
2153    /// Last update timestamp (Unix millis)
2154    pub last_update: i64,
2155    /// Optional scenario command piggybacked on cell (e.g., "START_SCENARIO",
2156    /// "STOP_SCENARIO")
2157    pub scenario_command: Option<String>,
2158}
2159
2160/// Track category enumeration
2161#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2162pub enum TrackCategory {
2163    Person,
2164    Vehicle,
2165    Aircraft,
2166    Vessel,
2167    Installation,
2168    Unknown,
2169}
2170
2171impl TrackCategory {
2172    fn from_str(s: &str) -> Self {
2173        match s.to_uppercase().as_str() {
2174            "PERSON" => Self::Person,
2175            "VEHICLE" => Self::Vehicle,
2176            "AIRCRAFT" => Self::Aircraft,
2177            "VESSEL" => Self::Vessel,
2178            "INSTALLATION" => Self::Installation,
2179            _ => Self::Unknown,
2180        }
2181    }
2182
2183    fn as_str(&self) -> &'static str {
2184        match self {
2185            Self::Person => "PERSON",
2186            Self::Vehicle => "VEHICLE",
2187            Self::Aircraft => "AIRCRAFT",
2188            Self::Vessel => "VESSEL",
2189            Self::Installation => "INSTALLATION",
2190            Self::Unknown => "UNKNOWN",
2191        }
2192    }
2193}
2194
2195/// Track information for display
2196#[derive(Debug, Clone, uniffi::Record)]
2197pub struct TrackInfo {
2198    /// Unique track identifier
2199    pub id: String,
2200    /// Source node that detected this track
2201    pub source_node: String,
2202    /// Cell ID that owns this track (if any)
2203    pub cell_id: Option<String>,
2204    /// Formation ID (if any)
2205    pub formation_id: Option<String>,
2206    /// Track latitude (WGS84)
2207    pub lat: f64,
2208    /// Track longitude (WGS84)
2209    pub lon: f64,
2210    /// Height above ellipsoid (meters, optional)
2211    pub hae: Option<f64>,
2212    /// Circular error probable (meters, optional)
2213    pub cep: Option<f64>,
2214    /// Heading in degrees (0 = North, optional)
2215    pub heading: Option<f64>,
2216    /// Speed in m/s (optional)
2217    pub speed: Option<f64>,
2218    /// MIL-STD-2525 classification or category
2219    pub classification: String,
2220    /// Detection confidence (0.0 - 1.0)
2221    pub confidence: f64,
2222    /// Track category
2223    pub category: TrackCategory,
2224    /// Created timestamp (Unix millis)
2225    pub created_at: i64,
2226    /// Last update timestamp (Unix millis)
2227    pub last_update: i64,
2228    /// Additional key-value attributes (callsign, image chip data, etc.)
2229    pub attributes: HashMap<String, String>,
2230}
2231
2232/// Node status enumeration
2233#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2234pub enum NodeStatus {
2235    /// Node is ready
2236    Ready,
2237    /// Node is active
2238    Active,
2239    /// Node has degraded capability
2240    Degraded,
2241    /// Node is offline
2242    Offline,
2243    /// Node is loading/initializing
2244    Loading,
2245}
2246
2247impl NodeStatus {
2248    fn from_str(s: &str) -> Self {
2249        match s.to_uppercase().as_str() {
2250            "READY" => Self::Ready,
2251            "ACTIVE" => Self::Active,
2252            "DEGRADED" => Self::Degraded,
2253            "OFFLINE" => Self::Offline,
2254            "LOADING" => Self::Loading,
2255            _ => Self::Offline,
2256        }
2257    }
2258
2259    pub fn as_str(&self) -> &'static str {
2260        match self {
2261            Self::Ready => "READY",
2262            Self::Active => "ACTIVE",
2263            Self::Degraded => "DEGRADED",
2264            Self::Offline => "OFFLINE",
2265            Self::Loading => "LOADING",
2266        }
2267    }
2268}
2269
2270/// Node information for display
2271#[derive(Debug, Clone, uniffi::Record)]
2272pub struct NodeInfo {
2273    /// Unique node identifier
2274    pub id: String,
2275    /// Node type (e.g., "UGV", "UAV", "Soldier System")
2276    pub node_type: String,
2277    /// Node name/callsign
2278    pub name: String,
2279    /// Node status
2280    pub status: NodeStatus,
2281    /// Node latitude (WGS84)
2282    pub lat: f64,
2283    /// Node longitude (WGS84)
2284    pub lon: f64,
2285    /// Height above ellipsoid (meters, optional)
2286    pub hae: Option<f64>,
2287    /// Readiness level (0.0 - 1.0)
2288    pub readiness: f64,
2289    /// List of capabilities
2290    pub capabilities: Vec<String>,
2291    /// Cell membership (if any)
2292    pub cell_id: Option<String>,
2293    /// Battery / fuel percentage (0–100). Optional because not every
2294    /// node has a measurable battery (fixed sensors, pre-lock
2295    /// watches), and legacy publishes from pre-2026-05-08 hosts didn't
2296    /// carry the field. Wire key: `battery_percent`. See
2297    /// [`parse_battery_percent`] for the clamp + None semantics.
2298    pub battery_percent: Option<i32>,
2299    /// Heart rate in BPM, sourced from wearable sensors (WearOS watch,
2300    /// M5Stack health). Wire key: `heart_rate`. Required to surface a
2301    /// vitals indicator on the operator card; absent on node types
2302    /// that don't carry a wearable. See [`parse_heart_rate`] for the
2303    /// clamp + None semantics.
2304    pub heart_rate: Option<i32>,
2305    /// Last heartbeat timestamp (Unix millis). Defaults to `0` when
2306    /// the publisher omits the field, surfaced to the UI as
2307    /// "1970-01-01 stale" — different intent from `battery_percent`'s
2308    /// `None` ("unknown sensor state"). Don't fold this into the same
2309    /// `Option<T>` shape: a missing heartbeat *is* a stale-record
2310    /// signal, not absence-of-data, and the node-overlay code uses
2311    /// the time delta directly without a None-check branch.
2312    pub last_heartbeat: i64,
2313}
2314
2315/// Operator-placed map marker — the typed shape every peer renders
2316/// in the Peat Markers panel and on the MapView (ADR-035 Universal
2317/// Document transport, "markers" collection).
2318///
2319/// Origin-agnostic: this struct is what the local doc store holds,
2320/// independent of which peer published it. The plugin's mental model
2321/// is "created somewhere, synced everywhere, displayed consistently"
2322/// — `MarkerInfo` is the synced shape, the wire transport is
2323/// invisible above this surface.
2324///
2325/// Wire-key parity with the JSON the prior raw-JSON publish path
2326/// produced (uid, type, lat, lon, hae, ts, callsign, color), so the
2327/// migration to the typed API is wire-compatible: docs published by
2328/// the old raw-JSON path round-trip cleanly into `MarkerInfo`.
2329#[derive(Debug, Clone, uniffi::Record)]
2330pub struct MarkerInfo {
2331    /// Unique marker identifier — the operator-placed UID, typically
2332    /// UUID-shaped (e.g. `4ae7b0a0-1995-447c-...`).
2333    pub uid: String,
2334    /// CoT 2525-style type code (e.g. `"a-f-G-U-C"` for friendly
2335    /// ground unit combat, `"b-m-p-w"` for waypoint).
2336    pub marker_type: String,
2337    /// Latitude (WGS84).
2338    pub lat: f64,
2339    /// Longitude (WGS84).
2340    pub lon: f64,
2341    /// Height above ellipsoid (meters). `None` when the publisher
2342    /// had no altitude fix; receivers render at ground level.
2343    pub hae: Option<f64>,
2344    /// Unix epoch milliseconds — the publisher's clock at marker
2345    /// drop time. Receivers DON'T treat this as a presence-staleness
2346    /// timestamp (markers persist until deleted, unlike nodes);
2347    /// it's purely "when did the operator drop this pin."
2348    pub ts: i64,
2349    /// Operator callsign of the publisher. `None` when the publisher
2350    /// didn't stamp it.
2351    pub callsign: Option<String>,
2352    /// Marker color (consumer-defined encoding — commonly a 32-bit
2353    /// ARGB integer, sign-extended). `None` when default coloring
2354    /// applies.
2355    pub color: Option<i32>,
2356    /// Cell membership (organizational unit within mesh), if scoped.
2357    /// `None` for cell-agnostic markers.
2358    pub cell_id: Option<String>,
2359    /// Soft-delete sentinel. When `true`, the marker is a tombstone
2360    /// — peers sync the deletion (CRDT keeps the entry so concurrent
2361    /// edits resolve consistently) but consumer UIs filter it out
2362    /// of "current markers" views. peat-mesh's fan-out today does
2363    /// NOT propagate `ChangeEvent::Removed` (Slice 2 work), so the
2364    /// soft-delete-sentinel pattern is the only way to communicate
2365    /// deletions across the mesh until that lands. Wire key: `_deleted`
2366    /// (matches the peat-mesh `transport::document_codec` synthesis
2367    /// convention from PR #103).
2368    pub deleted: bool,
2369}
2370
2371// Wire-shape contract for `Option<T>` fields on `NodeInfo`
2372// (Rust-side emit/parse only; downstream consumers in other repos
2373// have their own contracts).
2374//
2375// - **Emit:** `serialize_node_json` and `serialize_nodes_get_json` both render
2376//   `Option::None` as JSON `null` via `serde_json::json!` macro semantics.
2377//   There is no second emit shape from this codec.
2378//
2379// - **Parse:** `parse_node_json` and `parse_node_publish_json` both treat JSON
2380//   `null` AND a missing key the same way — both yield `None`.
2381//   `serde_json::Value` indexing returns `Value::Null` for missing keys, and
2382//   the typed accessors (`as_i64`, `as_str`, …) return `None` on a null
2383//   variant. So receivers don't need to distinguish "absent" from "explicit
2384//   null" — they're equivalent on the read side. Locked in by
2385//   `legacy_json_without_battery_or_heart_parses_with_none` (absent) and
2386//   `battery_and_heart_reject_non_numeric` (explicit null).
2387//
2388// - **Forward-compat:** parsers ignore unknown keys. Any wire shape a
2389//   future-version peer adds passes through unchanged.
2390
2391/// Command status enumeration
2392#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2393pub enum CommandStatus {
2394    /// Command is pending execution
2395    Pending,
2396    /// Command is being executed
2397    Executing,
2398    /// Command completed successfully
2399    Completed,
2400    /// Command failed
2401    Failed,
2402    /// Command was cancelled
2403    Cancelled,
2404}
2405
2406impl CommandStatus {
2407    fn from_str(s: &str) -> Self {
2408        match s.to_uppercase().as_str() {
2409            "PENDING" => Self::Pending,
2410            "EXECUTING" => Self::Executing,
2411            "COMPLETED" => Self::Completed,
2412            "FAILED" => Self::Failed,
2413            "CANCELLED" => Self::Cancelled,
2414            _ => Self::Pending,
2415        }
2416    }
2417
2418    fn as_str(&self) -> &'static str {
2419        match self {
2420            Self::Pending => "PENDING",
2421            Self::Executing => "EXECUTING",
2422            Self::Completed => "COMPLETED",
2423            Self::Failed => "FAILED",
2424            Self::Cancelled => "CANCELLED",
2425        }
2426    }
2427}
2428
2429/// Command information for C2
2430#[derive(Debug, Clone, uniffi::Record)]
2431pub struct CommandInfo {
2432    /// Unique command identifier
2433    pub id: String,
2434    /// Command type (e.g., "TRACK_TARGET", "MOVE", "ABORT")
2435    pub command_type: String,
2436    /// Target cell or node ID
2437    pub target_id: String,
2438    /// Command parameters as JSON string
2439    pub parameters: String,
2440    /// Command priority (1-5, 1 = highest)
2441    pub priority: u8,
2442    /// Command status
2443    pub status: CommandStatus,
2444    /// Originator ID
2445    pub originator: String,
2446    /// Created timestamp (Unix millis)
2447    pub created_at: i64,
2448    /// Last update timestamp (Unix millis)
2449    pub last_update: i64,
2450}
2451
2452// =============================================================================
2453// PeatNode Extensions for Typed Data Access
2454// =============================================================================
2455
2456#[cfg(feature = "sync")]
2457#[uniffi::export]
2458impl PeatNode {
2459    // -------------------------------------------------------------------------
2460    // Cell Operations
2461    // -------------------------------------------------------------------------
2462
2463    /// Get all cells from the sync document
2464    pub fn get_cells(&self) -> Result<Vec<CellInfo>, PeatError> {
2465        self.runtime.block_on(async {
2466            let backend = &self.storage_backend;
2467            let coll = backend.collection(collections::CELLS);
2468
2469            let docs = coll
2470                .scan()
2471                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2472
2473            let mut cells = Vec::new();
2474            for (id, data) in docs {
2475                if let Ok(json) = String::from_utf8(data) {
2476                    if let Ok(cell) = parse_cell_json(&id, &json) {
2477                        cells.push(cell);
2478                    }
2479                }
2480            }
2481            Ok(cells)
2482        })
2483    }
2484
2485    /// Get a specific cell by ID
2486    pub fn get_cell(&self, cell_id: &str) -> Result<Option<CellInfo>, PeatError> {
2487        self.runtime.block_on(async {
2488            let backend = &self.storage_backend;
2489            let coll = backend.collection(collections::CELLS);
2490
2491            match coll.get(cell_id) {
2492                Ok(Some(data)) => {
2493                    let json = String::from_utf8(data).map_err(|e| PeatError::StorageError {
2494                        msg: format!("Invalid UTF-8: {}", e),
2495                    })?;
2496                    let cell = parse_cell_json(cell_id, &json)?;
2497                    Ok(Some(cell))
2498                }
2499                Ok(None) => Ok(None),
2500                Err(e) => Err(PeatError::StorageError { msg: e.to_string() }),
2501            }
2502        })
2503    }
2504
2505    /// Store a cell
2506    pub fn put_cell(&self, cell: CellInfo) -> Result<(), PeatError> {
2507        let json = serialize_cell_json(&cell)?;
2508        self.runtime.block_on(async {
2509            let backend = &self.storage_backend;
2510            let coll = backend.collection(collections::CELLS);
2511            coll.upsert(&cell.id, json.into_bytes())
2512                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2513        })
2514    }
2515
2516    // -------------------------------------------------------------------------
2517    // Track Operations
2518    // -------------------------------------------------------------------------
2519
2520    /// Get all tracks from the sync document.
2521    ///
2522    /// Reads via `peat_mesh::Node::query(...)` so the writer/reader API
2523    /// stays consistent with `ingest_position_via_translator`'s
2524    /// `Node::publish_with_origin` path. The earlier implementation
2525    /// scanned `AutomergeBackend::collection(...).scan()` directly,
2526    /// expecting the bytes to be flat JSON of the original body — but
2527    /// `publish_with_origin` writes a Document whose Automerge map
2528    /// shape doesn't match that expectation, so every body field came
2529    /// back at `parse_track_json`'s `unwrap_or` defaults (peat#832).
2530    /// Going through `Node::query` decodes the Document fields
2531    /// properly and the read result matches what the writer published.
2532    /// The `track_tests::ingest_position_via_translator_then_get_tracks_preserves_body`
2533    /// test locks this in.
2534    pub fn get_tracks(&self) -> Result<Vec<TrackInfo>, PeatError> {
2535        use peat_mesh::sync::types::Query;
2536        self.runtime.block_on(async {
2537            let docs = self
2538                .node
2539                .query(collections::TRACKS, &Query::All)
2540                .await
2541                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2542
2543            let mut tracks = Vec::with_capacity(docs.len());
2544            for doc in docs {
2545                if let Some(id) = doc.id.clone() {
2546                    if let Ok(track) = track_from_document(&id, &doc) {
2547                        tracks.push(track);
2548                    }
2549                }
2550            }
2551            Ok(tracks)
2552        })
2553    }
2554
2555    /// Get a specific track by ID. Routes through `Node::get` for the
2556    /// same writer/reader symmetry reason as `get_tracks` (peat#832).
2557    pub fn get_track(&self, track_id: &str) -> Result<Option<TrackInfo>, PeatError> {
2558        self.runtime.block_on(async {
2559            let id = track_id.to_string();
2560            match self.node.get(collections::TRACKS, &id).await {
2561                Ok(Some(doc)) => Ok(Some(track_from_document(track_id, &doc)?)),
2562                Ok(None) => Ok(None),
2563                Err(e) => Err(PeatError::StorageError { msg: e.to_string() }),
2564            }
2565        })
2566    }
2567
2568    /// Store a track. Publishes through `Node::publish` so the
2569    /// resulting Document lives in the same storage namespace
2570    /// `Node::query` / `Node::get` read from — the BLE-bridged
2571    /// `ingest_position_via_translator` path already publishes this
2572    /// way, so unifying the typed `put_track` path keeps writer/reader
2573    /// symmetric for both publish surfaces (peat#832).
2574    ///
2575    /// Behavioral change vs pre-#836: this now fires through
2576    /// `TransportManager` fan-out (the `Node::publish` path emits a
2577    /// `ChangeEvent` that BLE / iroh transport drains observe), where
2578    /// the pre-fix `coll.upsert(json_bytes)` only emitted the
2579    /// in-process observer broadcast. No production caller exists
2580    /// today (production tracks come in via `ingestPositionJni`), so
2581    /// the change is observable only via UniFFI Kotlin / Swift
2582    /// consumers if any appear later. Documented here so the next
2583    /// reader doesn't have to re-trace the change to find out.
2584    pub fn put_track(&self, track: TrackInfo) -> Result<(), PeatError> {
2585        let doc = track_to_document(&track)?;
2586        self.runtime.block_on(async {
2587            self.node
2588                .publish(collections::TRACKS, doc)
2589                .await
2590                .map(|_id| ())
2591                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2592        })
2593    }
2594
2595    // -------------------------------------------------------------------------
2596    // Node Operations
2597    // -------------------------------------------------------------------------
2598
2599    /// Get all nodes from the sync document
2600    pub fn get_nodes(&self) -> Result<Vec<NodeInfo>, PeatError> {
2601        self.runtime.block_on(async {
2602            let backend = &self.storage_backend;
2603            let coll = backend.collection(collections::NODES);
2604
2605            let docs = coll
2606                .scan()
2607                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2608
2609            let mut nodes = Vec::new();
2610            for (id, data) in docs {
2611                if let Ok(json) = String::from_utf8(data) {
2612                    if let Ok(node) = parse_node_json(&id, &json) {
2613                        nodes.push(node);
2614                    }
2615                }
2616            }
2617            Ok(nodes)
2618        })
2619    }
2620
2621    /// Store a node
2622    pub fn put_node(&self, node: NodeInfo) -> Result<(), PeatError> {
2623        let json = serialize_node_json(&node)?;
2624        self.runtime.block_on(async {
2625            let backend = &self.storage_backend;
2626            let coll = backend.collection(collections::NODES);
2627            coll.upsert(&node.id, json.into_bytes())
2628                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2629        })
2630    }
2631
2632    // -------------------------------------------------------------------------
2633    // Marker Operations (operator-placed map pins, synced via ADR-035
2634    // Universal Document transport)
2635    // -------------------------------------------------------------------------
2636
2637    /// Get all markers from the sync document.
2638    ///
2639    /// Returns the canonical typed list of operator-placed pins
2640    /// across the mesh. Origin-agnostic — locally-created and
2641    /// peer-synced markers are indistinguishable in the result.
2642    /// Plugin consumers (PeatMapComponent's periodic refresh, the
2643    /// Peat Markers panel readout) call this and render every entry
2644    /// with the same code path.
2645    pub fn get_markers(&self) -> Result<Vec<MarkerInfo>, PeatError> {
2646        self.runtime.block_on(async {
2647            let backend = &self.storage_backend;
2648            let coll = backend.collection(collections::MARKERS);
2649
2650            let docs = coll
2651                .scan()
2652                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2653
2654            let mut markers = Vec::new();
2655            for (id, data) in docs {
2656                let json_str = String::from_utf8_lossy(&data);
2657                match parse_marker_publish_json(&id, &json_str) {
2658                    Ok(m) => markers.push(m),
2659                    Err(_) => {
2660                        // Malformed entry — skip silently. Same shape
2661                        // as get_nodes / get_commands handle parse
2662                        // errors: don't poison the whole list with one
2663                        // bad doc.
2664                    }
2665                }
2666            }
2667            Ok(markers)
2668        })
2669    }
2670
2671    /// Store a marker.
2672    ///
2673    /// Persists into the `markers` collection. peat-mesh's fan-out
2674    /// observes the change and routes via the registered transports
2675    /// (universal-Document path on BLE via LiteBridgeTranslator,
2676    /// iroh sync for cross-mesh peers). Receivers see the same
2677    /// `MarkerInfo` shape on their side.
2678    pub fn put_marker(&self, marker: MarkerInfo) -> Result<(), PeatError> {
2679        let json = serialize_marker_json(&marker)?;
2680        let uid = marker.uid.clone();
2681        self.runtime.block_on(async {
2682            let backend = &self.storage_backend;
2683            let coll = backend.collection(collections::MARKERS);
2684            coll.upsert(&uid, json.into_bytes())
2685                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2686        })
2687    }
2688
2689    // -------------------------------------------------------------------------
2690    // Command Operations (C2)
2691    // -------------------------------------------------------------------------
2692
2693    /// Get all pending commands
2694    pub fn get_commands(&self) -> Result<Vec<CommandInfo>, PeatError> {
2695        self.runtime.block_on(async {
2696            let backend = &self.storage_backend;
2697            let coll = backend.collection(collections::COMMANDS);
2698
2699            let docs = coll
2700                .scan()
2701                .map_err(|e| PeatError::StorageError { msg: e.to_string() })?;
2702
2703            let mut commands = Vec::new();
2704            for (id, data) in docs {
2705                if let Ok(json) = String::from_utf8(data) {
2706                    if let Ok(cmd) = parse_command_json(&id, &json) {
2707                        commands.push(cmd);
2708                    }
2709                }
2710            }
2711            Ok(commands)
2712        })
2713    }
2714
2715    /// Store a command (for C2 issuance)
2716    pub fn put_command(&self, command: CommandInfo) -> Result<(), PeatError> {
2717        let json = serialize_command_json(&command)?;
2718        self.runtime.block_on(async {
2719            let backend = &self.storage_backend;
2720            let coll = backend.collection(collections::COMMANDS);
2721            coll.upsert(&command.id, json.into_bytes())
2722                .map_err(|e| PeatError::StorageError { msg: e.to_string() })
2723        })
2724    }
2725}
2726
2727// =============================================================================
2728// Blob Transfer (ADR-060) — not UniFFI-exported; reached via direct JNI only
2729// =============================================================================
2730
2731#[cfg(feature = "sync")]
2732impl PeatNode {
2733    /// Enable the parallel blob-transfer endpoint.
2734    ///
2735    /// Constructs a `NetworkedIrohBlobStore` on the tokio runtime owned by
2736    /// this node and stores it for later use via `blob_put` / `blob_get`.
2737    /// Bind address defaults to `0.0.0.0:0` (ephemeral) when None.
2738    pub fn enable_blob_transfer(
2739        &self,
2740        bind_addr: Option<std::net::SocketAddr>,
2741    ) -> Result<(), PeatError> {
2742        let blob_dir = self.storage_path.join("blobs");
2743        std::fs::create_dir_all(&blob_dir).map_err(|e| PeatError::StorageError {
2744            msg: format!("Failed to create blob dir {:?}: {}", blob_dir, e),
2745        })?;
2746
2747        let config = PeatMeshIrohConfig {
2748            bind_addr,
2749            ..Default::default()
2750        };
2751
2752        let store = self
2753            .runtime
2754            .block_on(NetworkedIrohBlobStore::from_config(blob_dir, &config))
2755            .map_err(|e| PeatError::SyncError {
2756                msg: format!("Failed to create blob store: {}", e),
2757            })?;
2758
2759        #[cfg(target_os = "android")]
2760        android_log(&format!(
2761            "Blob transfer enabled. EndpointId={}",
2762            store.endpoint_id().fmt_short()
2763        ));
2764
2765        let mut slot = self.blob_store.write().map_err(|_| PeatError::SyncError {
2766            msg: "blob_store lock poisoned".to_string(),
2767        })?;
2768        *slot = Some(store);
2769        Ok(())
2770    }
2771
2772    /// Add a known blob peer by hex EndpointId and socket address.
2773    /// Uses peat-mesh's `add_peer_from_hex` so no iroh types cross into
2774    /// peat-ffi.
2775    pub fn blob_add_peer(&self, peer_id_hex: &str, address: &str) -> Result<(), PeatError> {
2776        let store_guard = self.blob_store.read().map_err(|_| PeatError::SyncError {
2777            msg: "blob_store lock poisoned".to_string(),
2778        })?;
2779        let store = store_guard.as_ref().ok_or(PeatError::SyncError {
2780            msg: "blob transfer not enabled".to_string(),
2781        })?;
2782
2783        let store_clone = Arc::clone(store);
2784        let hex = peer_id_hex.to_string();
2785        let addr = address.to_string();
2786        self.runtime
2787            .block_on(async move { store_clone.add_peer_from_hex(&hex, &addr).await })
2788            .map_err(|e| PeatError::SyncError {
2789                msg: format!("blob_add_peer: {}", e),
2790            })?;
2791
2792        #[cfg(target_os = "android")]
2793        android_log(&format!(
2794            "Blob peer added: {} at {}",
2795            &peer_id_hex[..16.min(peer_id_hex.len())],
2796            address
2797        ));
2798
2799        Ok(())
2800    }
2801
2802    /// Store bytes in the local blob store. Returns the content hash as hex.
2803    pub fn blob_put(&self, data: &[u8], content_type: &str) -> Result<String, PeatError> {
2804        let store_guard = self.blob_store.read().map_err(|_| PeatError::SyncError {
2805            msg: "blob_store lock poisoned".to_string(),
2806        })?;
2807        let store = store_guard.as_ref().ok_or(PeatError::SyncError {
2808            msg: "blob transfer not enabled".to_string(),
2809        })?;
2810
2811        let metadata = BlobMetadata {
2812            content_type: Some(content_type.to_string()),
2813            name: None,
2814            custom: Default::default(),
2815        };
2816
2817        let store_clone = Arc::clone(store);
2818        let data_vec = data.to_vec();
2819        let token = self
2820            .runtime
2821            .block_on(async move {
2822                store_clone
2823                    .create_blob_from_bytes(&data_vec, metadata)
2824                    .await
2825            })
2826            .map_err(|e| PeatError::StorageError {
2827                msg: format!("blob put failed: {}", e),
2828            })?;
2829
2830        Ok(token.hash.as_hex().to_string())
2831    }
2832
2833    /// Fetch blob bytes by content hash (hex). Tries local first, then
2834    /// known peers. Returns the bytes or an error.
2835    pub fn blob_get(&self, hash_hex: &str) -> Result<Vec<u8>, PeatError> {
2836        let store_guard = self.blob_store.read().map_err(|_| PeatError::SyncError {
2837            msg: "blob_store lock poisoned".to_string(),
2838        })?;
2839        let store = store_guard.as_ref().ok_or(PeatError::SyncError {
2840            msg: "blob transfer not enabled".to_string(),
2841        })?;
2842
2843        let token = BlobToken {
2844            hash: peat_mesh::storage::BlobHash(hash_hex.to_string()),
2845            size_bytes: 0, // unknown; fetch_blob doesn't use this for lookup
2846            metadata: BlobMetadata {
2847                content_type: None,
2848                name: None,
2849                custom: Default::default(),
2850            },
2851        };
2852
2853        let store_clone = Arc::clone(store);
2854        let handle = self
2855            .runtime
2856            .block_on(async move { store_clone.fetch_blob_simple(&token).await })
2857            .map_err(|e| PeatError::StorageError {
2858                msg: format!("blob fetch failed: {}", e),
2859            })?;
2860
2861        std::fs::read(&handle.path).map_err(|e| PeatError::StorageError {
2862            msg: format!("blob read failed: {}", e),
2863        })
2864    }
2865
2866    /// Check if a blob exists locally without network fetch.
2867    pub fn blob_exists_locally(&self, hash_hex: &str) -> bool {
2868        let store_guard = match self.blob_store.read() {
2869            Ok(g) => g,
2870            Err(_) => return false,
2871        };
2872        let store = match store_guard.as_ref() {
2873            Some(s) => s,
2874            None => return false,
2875        };
2876        let hash = peat_mesh::storage::BlobHash(hash_hex.to_string());
2877        store.blob_exists_locally(&hash)
2878    }
2879
2880    /// Get the blob endpoint ID as hex (returns None if blob transfer is
2881    /// disabled).
2882    pub fn blob_endpoint_id(&self) -> Option<String> {
2883        let store_guard = self.blob_store.read().ok()?;
2884        let store = store_guard.as_ref()?;
2885        Some(hex::encode(store.endpoint_id().as_bytes()))
2886    }
2887
2888    /// Get the blob endpoint's bound socket address as "ip:port".
2889    /// Useful for configuring remote peers and for tests.
2890    pub fn blob_bound_addr(&self) -> Option<String> {
2891        let store_guard = self.blob_store.read().ok()?;
2892        let store = store_guard.as_ref()?;
2893        store.bound_addr_string()
2894    }
2895}
2896
2897// =============================================================================
2898// JSON Serialization Helpers
2899// =============================================================================
2900
2901fn parse_cell_json(id: &str, json: &str) -> Result<CellInfo, PeatError> {
2902    let root: serde_json::Value =
2903        serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
2904            msg: format!("Invalid JSON: {}", e),
2905        })?;
2906    // Docs published through the node layer are wrapped as {id, fields:{..}};
2907    // flat (legacy) writes keep fields at the root. Read from `fields` if present.
2908    let v = match root.get("fields") {
2909        Some(f) if f.is_object() => f,
2910        _ => &root,
2911    };
2912
2913    Ok(CellInfo {
2914        id: id.to_string(),
2915        name: v["name"].as_str().unwrap_or(id).to_string(),
2916        status: CellStatus::from_str(v["status"].as_str().unwrap_or("OFFLINE")),
2917        node_count: v["node_count"].as_u64().unwrap_or(0) as u32,
2918        center_lat: v["center_lat"].as_f64().unwrap_or(0.0),
2919        center_lon: v["center_lon"].as_f64().unwrap_or(0.0),
2920        capabilities: v["capabilities"]
2921            .as_array()
2922            .map(|arr| {
2923                arr.iter()
2924                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
2925                    .collect()
2926            })
2927            .unwrap_or_default(),
2928        formation_id: v["formation_id"].as_str().map(|s| s.to_string()),
2929        leader_id: v["leader_id"].as_str().map(|s| s.to_string()),
2930        last_update: v["last_update"].as_i64().unwrap_or(0),
2931        scenario_command: v["scenario_command"].as_str().map(|s| s.to_string()),
2932    })
2933}
2934
2935fn serialize_cell_json(cell: &CellInfo) -> Result<String, PeatError> {
2936    let v = serde_json::json!({
2937        "name": cell.name,
2938        "status": cell.status.as_str(),
2939        "node_count": cell.node_count,
2940        "center_lat": cell.center_lat,
2941        "center_lon": cell.center_lon,
2942        "capabilities": cell.capabilities,
2943        "formation_id": cell.formation_id,
2944        "leader_id": cell.leader_id,
2945        "last_update": cell.last_update,
2946        "scenario_command": cell.scenario_command,
2947    });
2948    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
2949}
2950
2951/// Adapt a `TrackInfo` into a `peat_mesh::Document` for publishing.
2952///
2953/// Routes through the existing `serialize_track_json` so the body-field
2954/// encoding rules stay in one place — re-deserializing the JSON into a
2955/// `Map<String, Value>` and stuffing into `Document.fields` is the same
2956/// shape `peat_protocol::sync::ble_translation::value_to_mesh_document`
2957/// produces from the translator path. One extra serde round-trip per
2958/// `put_track`; acceptable for the consumer counts the plugin handles.
2959fn track_to_document(track: &TrackInfo) -> Result<peat_mesh::sync::types::Document, PeatError> {
2960    let json = serialize_track_json(track)?;
2961    let value: serde_json::Value =
2962        serde_json::from_str(&json).map_err(|e| PeatError::EncodingError {
2963            msg: format!("track_to_document: re-parse failed: {}", e),
2964        })?;
2965    let fields: std::collections::HashMap<String, serde_json::Value> = match value {
2966        serde_json::Value::Object(map) => map.into_iter().collect(),
2967        _ => std::collections::HashMap::new(),
2968    };
2969    Ok(peat_mesh::sync::types::Document {
2970        id: Some(track.id.clone()),
2971        fields,
2972        updated_at: std::time::SystemTime::now(),
2973    })
2974}
2975
2976/// Adapt a `peat_mesh::Document` into a `TrackInfo`.
2977///
2978/// Routes through the existing `parse_track_json` so the body-field
2979/// mapping rules stay in one place — `Document.fields` is a flat
2980/// `HashMap<String, Value>`, so re-emitting them as a JSON object is
2981/// a one-step adapter rather than a full reimplementation. The cost
2982/// is one extra serde_json round-trip per track on read; acceptable
2983/// for the consumer counts the plugin handles (single-digit
2984/// nodes × tens of tracks).
2985fn track_from_document(
2986    id: &str,
2987    doc: &peat_mesh::sync::types::Document,
2988) -> Result<TrackInfo, PeatError> {
2989    let body: serde_json::Map<String, serde_json::Value> = doc
2990        .fields
2991        .iter()
2992        .map(|(k, v)| (k.clone(), v.clone()))
2993        .collect();
2994    let json = serde_json::to_string(&serde_json::Value::Object(body))
2995        .map_err(|e| PeatError::EncodingError { msg: e.to_string() })?;
2996    parse_track_json(id, &json)
2997}
2998
2999fn parse_track_json(id: &str, json: &str) -> Result<TrackInfo, PeatError> {
3000    let v: serde_json::Value = serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
3001        msg: format!("Invalid JSON: {}", e),
3002    })?;
3003
3004    Ok(TrackInfo {
3005        id: id.to_string(),
3006        source_node: v["source_node"].as_str().unwrap_or("unknown").to_string(),
3007        cell_id: v["cell_id"].as_str().map(|s| s.to_string()),
3008        formation_id: v["formation_id"].as_str().map(|s| s.to_string()),
3009        lat: v["lat"].as_f64().unwrap_or(0.0),
3010        lon: v["lon"].as_f64().unwrap_or(0.0),
3011        hae: v["hae"].as_f64(),
3012        cep: v["cep"].as_f64(),
3013        heading: v["heading"].as_f64(),
3014        speed: v["speed"].as_f64(),
3015        classification: v["classification"].as_str().unwrap_or("a-u-G").to_string(),
3016        confidence: v["confidence"].as_f64().unwrap_or(0.5),
3017        category: TrackCategory::from_str(v["category"].as_str().unwrap_or("UNKNOWN")),
3018        created_at: v["created_at"].as_i64().unwrap_or(0),
3019        last_update: v["last_update"].as_i64().unwrap_or(0),
3020        attributes: v["attributes"]
3021            .as_object()
3022            .map(|obj| {
3023                obj.iter()
3024                    .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
3025                    .collect()
3026            })
3027            .unwrap_or_default(),
3028    })
3029}
3030
3031fn serialize_track_json(track: &TrackInfo) -> Result<String, PeatError> {
3032    let v = serde_json::json!({
3033        "source_node": track.source_node,
3034        "cell_id": track.cell_id,
3035        "formation_id": track.formation_id,
3036        "lat": track.lat,
3037        "lon": track.lon,
3038        "hae": track.hae,
3039        "cep": track.cep,
3040        "heading": track.heading,
3041        "speed": track.speed,
3042        "classification": track.classification,
3043        "confidence": track.confidence,
3044        "category": track.category.as_str(),
3045        "created_at": track.created_at,
3046        "last_update": track.last_update,
3047        "attributes": track.attributes,
3048    });
3049    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3050}
3051
3052fn parse_node_json(id: &str, json: &str) -> Result<NodeInfo, PeatError> {
3053    let root: serde_json::Value =
3054        serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
3055            msg: format!("Invalid JSON: {}", e),
3056        })?;
3057
3058    // Node docs published through the node layer are wrapped as
3059    // `{id, fields:{..}, updated_at}`; flat (legacy storage_backend) writes
3060    // keep the fields at the root. Read from `fields` when it's an object.
3061    let v = match root.get("fields") {
3062        Some(f) if f.is_object() => f,
3063        _ => &root,
3064    };
3065
3066    Ok(NodeInfo {
3067        id: id.to_string(),
3068        node_type: v["node_type"].as_str().unwrap_or("unknown").to_string(),
3069        name: v["name"].as_str().unwrap_or(id).to_string(),
3070        status: NodeStatus::from_str(v["status"].as_str().unwrap_or("OFFLINE")),
3071        lat: v["lat"].as_f64().unwrap_or(0.0),
3072        lon: v["lon"].as_f64().unwrap_or(0.0),
3073        hae: v["hae"].as_f64(),
3074        readiness: v["readiness"].as_f64().unwrap_or(0.0),
3075        capabilities: v["capabilities"]
3076            .as_array()
3077            .map(|arr| {
3078                arr.iter()
3079                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
3080                    .collect()
3081            })
3082            .unwrap_or_default(),
3083        cell_id: v["cell_id"].as_str().map(|s| s.to_string()),
3084        battery_percent: parse_battery_percent(&v["battery_percent"]),
3085        heart_rate: parse_heart_rate(&v["heart_rate"]),
3086        last_heartbeat: v["last_heartbeat"].as_i64().unwrap_or(0),
3087    })
3088}
3089
3090/// Parse a Kotlin-side `publishNodeJni` payload into a
3091/// `NodeInfo`.
3092///
3093/// Distinct from `parse_node_json` because the JNI publish path
3094/// supplies a few different defaults: `node_type` defaults to
3095/// `"SOLDIER"` here vs `"unknown"` in the storage parser; `status`
3096/// defaults to `"ACTIVE"` here vs `"OFFLINE"` for storage; `readiness`
3097/// defaults to `1.0` here vs `0.0`. The `last_heartbeat` field is
3098/// honored from the wire when present (with a `now() + 60s` clock-skew
3099/// clamp via `parse_publish_last_heartbeat`); falls back to local
3100/// `Utc::now()` only when the publisher omits it. See
3101/// [`parse_publish_last_heartbeat`] for the full semantics.
3102///
3103/// Centralizing this in a free function makes it directly
3104/// unit-testable and means the inline JNI path and the test suite
3105/// share the exact codec implementation — the duplication that hid
3106/// peat#835.
3107///
3108/// Errors:
3109/// - `InvalidInput` if the JSON is malformed or `id` is missing/empty (consumed
3110///   as the storage key downstream; an empty id would collide with
3111///   `getNodesJni`'s scan results).
3112fn parse_node_publish_json(json_str: &str) -> Result<NodeInfo, PeatError> {
3113    let v: serde_json::Value =
3114        serde_json::from_str(json_str).map_err(|e| PeatError::InvalidInput {
3115            msg: format!("publishNode: invalid JSON: {}", e),
3116        })?;
3117
3118    let id = match v["id"].as_str() {
3119        Some(id) if !id.is_empty() => id.to_string(),
3120        _ => {
3121            return Err(PeatError::InvalidInput {
3122                msg: "publishNode: missing or empty 'id' field".to_string(),
3123            });
3124        }
3125    };
3126
3127    Ok(NodeInfo {
3128        id,
3129        node_type: v["node_type"].as_str().unwrap_or("SOLDIER").to_string(),
3130        name: v["name"].as_str().unwrap_or("Unknown").to_string(),
3131        status: NodeStatus::from_str(v["status"].as_str().unwrap_or("ACTIVE")),
3132        lat: v["lat"].as_f64().unwrap_or(0.0),
3133        lon: v["lon"].as_f64().unwrap_or(0.0),
3134        hae: v["hae"].as_f64(),
3135        readiness: v["readiness"].as_f64().unwrap_or(1.0),
3136        capabilities: v["capabilities"]
3137            .as_array()
3138            .map(|arr| {
3139                arr.iter()
3140                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
3141                    .collect()
3142            })
3143            .unwrap_or_else(|| vec!["PLI".to_string()]),
3144        cell_id: v["cell_id"].as_str().map(|s| s.to_string()),
3145        battery_percent: parse_battery_percent(&v["battery_percent"]),
3146        heart_rate: parse_heart_rate(&v["heart_rate"]),
3147        last_heartbeat: parse_publish_last_heartbeat(&v["last_heartbeat"]),
3148    })
3149}
3150
3151/// Parse the `last_heartbeat` field on a publish-side JSON envelope.
3152///
3153/// Three intents we must honor faithfully:
3154/// 1. **Wire absent → stamp `now()`.** Real publishers (Kotlin self-PLI,
3155///    BLE-bridged peripheral relay) don't carry a timestamp; the JNI surface
3156///    always meant "this publish is fresh."
3157/// 2. **Wire `0` → preserve `0`.** Per `NodeInfo`'s field doc, `last_heartbeat
3158///    = 0` is the documented stale-record sentinel ("1970-01-01 stale"). The
3159///    earlier `> 0` filter silently overrode this — a publisher sending the
3160///    documented stale marker got `Utc::now()` back, the *opposite* signal.
3161///    That was a writer/reader-asymmetry regression of the same class peat#835
3162///    was opened to fix; round-4 drops the filter.
3163/// 3. **Wire absurdly far in the future → clamp to `now()`.** A peer with a
3164///    future-skewed clock can publish `i64::MAX` or any timestamp ahead of
3165///    local time; downstream Kotlin staleness UI consumes the value raw via
3166///    `getStalenessString` and would show the node as "always fresh." Cap
3167///    acceptance at `now() + 60_000ms` (60 s grace for legitimate clock drift
3168///    in distributed systems); beyond that, treat as adversarial /
3169///    misconfigured and stamp local `now()`.
3170///
3171/// 4. **Wire negative → collapse to the stale-marker (`0`).** Round-4 let
3172///    negatives pass through with a doc-comment claiming downstream time-delta
3173///    arithmetic still produced a sensible age; that's wrong: `now - i64::MIN`
3174///    overflows i64, and Kotlin `Long` subtraction silently wraps, producing
3175///    nonsense staleness output (or panic in Rust debug builds). Negative
3176///    timestamps are pathological — pre-epoch publish makes no sense in this
3177///    product — and collapsing them onto the documented stale-marker (`0`)
3178///    keeps the UI's arithmetic safe while preserving the "very stale" intent.
3179fn parse_publish_last_heartbeat(v: &serde_json::Value) -> i64 {
3180    let now_ms = chrono::Utc::now().timestamp_millis();
3181    // 60 s grace covers normal NTP drift between mobile devices on
3182    // unrelated networks; beyond that, the value is broken.
3183    const FUTURE_GRACE_MS: i64 = 60_000;
3184    let max_acceptable = now_ms.saturating_add(FUTURE_GRACE_MS);
3185    match v.as_i64() {
3186        Some(n) if n > max_acceptable => now_ms,
3187        // Collapse negatives to the documented stale-marker — both
3188        // bound the downstream Long-subtraction and preserve the
3189        // publisher's "very stale" intent unambiguously.
3190        Some(n) if n < 0 => 0,
3191        Some(n) => n,
3192        None => now_ms,
3193    }
3194}
3195
3196/// Serialize a slice of `NodeInfo` into the JSON-array shape
3197/// `getNodesJni` returns to Kotlin.
3198///
3199/// Mirror of [`parse_node_publish_json`] for the read-back path.
3200/// Pre-round-3 this was inlined inside the JNI function — that's the
3201/// duplicated-codec class peat#835 was opened to lock; extracting it
3202/// here makes the emit-side schema directly testable and keeps
3203/// writer/reader symmetry single-sourced.
3204///
3205/// Falls through to `"[]"` on serializer failure (the JNI surface
3206/// returned the same string on `get_nodes` errors before the
3207/// extraction; preserving that for back-compat).
3208///
3209/// Not gated on `feature = "sync"` even though the only caller
3210/// (`getNodesJni`) is — the body operates on `NodeInfo` and
3211/// `serde_json` only, and the mirror parser `serialize_node_json`
3212/// is unconditional. Asymmetric gating between the pair would be
3213/// confusing to maintainers and `cargo check --no-default-features`
3214/// wouldn't catch the inconsistency.
3215fn serialize_nodes_get_json(nodes: &[NodeInfo]) -> String {
3216    let json_array: Vec<serde_json::Value> = nodes
3217        .iter()
3218        .map(|p| {
3219            serde_json::json!({
3220                "id": p.id,
3221                "node_type": p.node_type,
3222                "name": p.name,
3223                "status": p.status.as_str(),
3224                "lat": p.lat,
3225                "lon": p.lon,
3226                "hae": p.hae,
3227                "readiness": p.readiness,
3228                "capabilities": p.capabilities,
3229                "cell_id": p.cell_id,
3230                "battery_percent": p.battery_percent,
3231                "heart_rate": p.heart_rate,
3232                "last_heartbeat": p.last_heartbeat,
3233            })
3234        })
3235        .collect();
3236    serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
3237}
3238
3239/// Coerce a JSON `Value` into a numeric value as i64.
3240///
3241/// Accepts both integer (`85`) and float (`85.0`, `85.5`) JSON
3242/// numbers; floats round half-away-from-zero per `f64::round()`.
3243/// Returns `None` for any other variant (string, null, array, object,
3244/// missing key).
3245///
3246/// Why both forms: serde_json maps JSON numbers into one of three
3247/// internal representations (i64 / u64 / f64), and `Value::as_i64`
3248/// only matches the first. A Kotlin publisher serializing
3249/// `Int.toDouble().toString()` (i.e. `"85.0"` reaches the parser as
3250/// the float variant), or any node whose JSON serializer renders
3251/// integers with a trailing `.0`, would silently drop the field
3252/// through the int-only path. That's the **same data-loss bug class
3253/// peat#835 was opened to lock**: a publisher writes a value and the
3254/// receiver decodes `None`, indistinguishable from "no sensor."
3255/// Empirically `serde_json::json!(85.0).as_i64() == None`; the float
3256/// fallback closes the gap.
3257///
3258/// **Precision contract — important for callers reusing this helper
3259/// outside of `parse_battery_percent` / `parse_heart_rate`**:
3260///
3261/// JSON Numbers above `i64::MAX` (i.e. stored as `u64` in serde_json,
3262/// 9.22e18..1.84e19) are unreachable by `as_i64()` and traverse the
3263/// `as_f64()` fallback. f64 has only 53 bits of mantissa, so values
3264/// above 2⁵³ (≈ 9.0e15) lose integer precision via that path —
3265/// e.g. `9_007_199_254_740_993_u64` round-trips through f64 as
3266/// `9_007_199_254_740_992`.
3267///
3268/// For `battery_percent` (0..=100) and `heart_rate` (0..=250) this is
3269/// inconsequential: the subsequent `clamp` truncates any
3270/// astronomically-large value to the same range end. Callers operating
3271/// on a wider range or needing exact integer fidelity above 2⁵³ should
3272/// pre-validate the wire shape (e.g. reject non-i64 Numbers explicitly)
3273/// rather than reuse this helper.
3274///
3275/// **Rounding mode**: `f64::round()` rounds half-away-from-zero
3276/// (`85.5 → 86`, `-85.5 → -86`). If a future caller depends on
3277/// banker's-rounding or half-to-even semantics, switch to
3278/// `f.round_ties_even()` (Rust 1.77+) and update tests accordingly.
3279fn coerce_json_number_to_i64(v: &serde_json::Value) -> Option<i64> {
3280    if let Some(n) = v.as_i64() {
3281        return Some(n);
3282    }
3283    // `f64::round() as i64` is saturating in current Rust (1.45+):
3284    // `f64::INFINITY as i64 == i64::MAX`, NaN as i64 == 0. Both
3285    // outcomes get clamped by the caller into the logical range, so
3286    // pathological floats fail-safe rather than panic.
3287    v.as_f64().map(|f| f.round() as i64)
3288}
3289
3290/// Parse a JSON `Value` into a battery percentage, clamping into the
3291/// physical 0..=100 range.
3292///
3293/// - Accepts integer or float JSON numbers (`85`, `85.0`, `85.5` → `85`). See
3294///   [`coerce_json_number_to_i64`] for why both forms.
3295/// - Numeric values clamp on out-of-range. The silent-`None`-on- overflow shape
3296///   `as_i64().and_then(|n| i32::try_from(n).ok())` produced was the same bug
3297///   class peat#835 was opened to prevent: a pathological 2³² `battery_percent`
3298///   becomes "no battery sensor," visually identical to the legitimate `None`
3299///   case. Clamp fails-safe to 0 or 100 instead.
3300/// - Non-numeric (string, object, missing key, JSON null) returns `None`. We
3301///   accept "no battery sensor" but reject silent type coercion — a `"85"`
3302///   *string* wire payload is a publisher bug, not a value to interpret.
3303///
3304/// Wire form: number in 0–100 (integer or float), or `null` / absent
3305/// for "unknown."
3306fn parse_battery_percent(v: &serde_json::Value) -> Option<i32> {
3307    let n = coerce_json_number_to_i64(v)?;
3308    Some(n.clamp(0, 100) as i32)
3309}
3310
3311/// Parse a JSON `Value` into a heart rate (BPM), clamping into the
3312/// 0..=250 range.
3313///
3314/// - Accepts integer or float JSON numbers; floats round.
3315/// - Lower bound is **0**, not 30: athletic resting bradycardia can dip into
3316///   the 20s, and a sensor reporting 0/asystole is a real emergency signal that
3317///   the UI should surface, not silently round up. The earlier 30 floor masked
3318///   these. Upper bound stays 250 (well above maximal exertion ~220−age) to
3319///   catch overflow payloads.
3320/// - Non-numeric returns `None` ("no wearable sensor present").
3321///
3322/// Wire form: number in 0–250 (integer or float), or `null` / absent
3323/// for "unknown."
3324fn parse_heart_rate(v: &serde_json::Value) -> Option<i32> {
3325    let n = coerce_json_number_to_i64(v)?;
3326    Some(n.clamp(0, 250) as i32)
3327}
3328
3329/// Parse a `MarkerInfo` from the wire JSON (publish-side), with
3330/// graceful field absence: missing optional fields → `None`, missing
3331/// required geo (`uid`/`type`/`lat`/`lon`) → `InvalidInput`.
3332///
3333/// The parser is wire-compatible with the JSON the prior raw-JSON
3334/// publish path produced — see the field comments on `MarkerInfo`
3335/// for key-by-key parity. The `id` argument lets the scan-side
3336/// caller supply the doc id (the doc store's key) when it's not in
3337/// the body; we accept either source as the `uid`.
3338fn parse_marker_publish_json(id: &str, json_str: &str) -> Result<MarkerInfo, PeatError> {
3339    let v: serde_json::Value =
3340        serde_json::from_str(json_str).map_err(|e| PeatError::InvalidInput {
3341            msg: format!("marker JSON: {}", e),
3342        })?;
3343
3344    let uid = v["uid"]
3345        .as_str()
3346        .map(|s| s.to_string())
3347        .filter(|s| !s.is_empty())
3348        .unwrap_or_else(|| id.to_string());
3349    if uid.is_empty() {
3350        return Err(PeatError::InvalidInput {
3351            msg: "marker missing uid (and no doc-store id supplied)".to_string(),
3352        });
3353    }
3354
3355    // Deletion-sentinel detection. A tombstone marker is just
3356    // `{uid, _deleted: true}` — type/lat/lon optional. Receivers
3357    // know to filter the entry out of "current markers" views. We
3358    // need the deletion to ride the same wire envelope as a normal
3359    // marker (peat-mesh fan-out doesn't propagate Removed events
3360    // today), so the doc-store retains the tombstone for CRDT
3361    // consistency.
3362    let deleted = v["_deleted"].as_bool().unwrap_or(false);
3363
3364    let marker_type = if deleted {
3365        v["type"]
3366            .as_str()
3367            .unwrap_or(TOMBSTONE_PLACEHOLDER_TYPE)
3368            .to_string()
3369    } else {
3370        v["type"]
3371            .as_str()
3372            .ok_or_else(|| PeatError::InvalidInput {
3373                msg: format!("marker {uid} missing CoT type"),
3374            })?
3375            .to_string()
3376    };
3377    let lat = if deleted {
3378        v["lat"].as_f64().unwrap_or(0.0)
3379    } else {
3380        v["lat"].as_f64().ok_or_else(|| PeatError::InvalidInput {
3381            msg: format!("marker {uid} missing lat"),
3382        })?
3383    };
3384    let lon = if deleted {
3385        v["lon"].as_f64().unwrap_or(0.0)
3386    } else {
3387        v["lon"].as_f64().ok_or_else(|| PeatError::InvalidInput {
3388            msg: format!("marker {uid} missing lon"),
3389        })?
3390    };
3391    let hae = v["hae"].as_f64();
3392    let ts = v["ts"].as_i64().unwrap_or(0);
3393    let callsign = v["callsign"]
3394        .as_str()
3395        .filter(|s| !s.is_empty())
3396        .map(|s| s.to_string());
3397    let color = coerce_json_number_to_i64(&v["color"]).map(|n| n as i32);
3398    let cell_id = v["cell_id"]
3399        .as_str()
3400        .filter(|s| !s.is_empty())
3401        .map(|s| s.to_string());
3402
3403    Ok(MarkerInfo {
3404        uid,
3405        marker_type,
3406        lat,
3407        lon,
3408        hae,
3409        ts,
3410        callsign,
3411        color,
3412        cell_id,
3413        deleted,
3414    })
3415}
3416
3417/// Serialize the typed list to the JSON shape `getMarkersJni`
3418/// returns. Wire-key parity with `serialize_marker_json` so a doc
3419/// round-trips through the get path identically to the put path.
3420fn serialize_markers_get_json(markers: &[MarkerInfo]) -> String {
3421    let json_array: Vec<serde_json::Value> = markers
3422        .iter()
3423        .map(|m| {
3424            let mut obj = serde_json::json!({
3425                "uid": m.uid,
3426                "type": m.marker_type,
3427                "lat": m.lat,
3428                "lon": m.lon,
3429                "hae": m.hae,
3430                "ts": m.ts,
3431                "callsign": m.callsign,
3432                "color": m.color,
3433                "cell_id": m.cell_id,
3434            });
3435            if m.deleted {
3436                obj["_deleted"] = serde_json::Value::Bool(true);
3437            }
3438            obj
3439        })
3440        .collect();
3441    // `serde_json::to_string` on a `Vec<serde_json::Value>` composed
3442    // entirely of primitives, booleans, strings, and JSON objects we
3443    // just constructed is infallible — the failure modes are
3444    // I/O on `to_writer`, non-string map keys, or NaN floats without
3445    // the `arbitrary_precision` feature. None of those can arise
3446    // from this shape, so the unwrap-to-`"[]"` fallback is dead code
3447    // that exists only because the signature returns `String` (not
3448    // `Result<String, _>`) for symmetry with the JNI consumers'
3449    // `Ok("[]")` semantics on storage error. If a future field type
3450    // change introduces a fallible shape (e.g., `f64::NAN` for a
3451    // missing-altitude sentinel), promote this to `Result` and
3452    // surface the error to the caller.
3453    serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
3454}
3455
3456/// Serialize a single marker for `put_marker` storage. Wire-key
3457/// parity with `serialize_markers_get_json` (single object instead
3458/// of array — same key set, same shapes) so a doc written via
3459/// `put_marker` reads identically through `get_markers`.
3460fn serialize_marker_json(marker: &MarkerInfo) -> Result<String, PeatError> {
3461    let mut v = serde_json::json!({
3462        "uid": marker.uid,
3463        "type": marker.marker_type,
3464        "lat": marker.lat,
3465        "lon": marker.lon,
3466        "hae": marker.hae,
3467        "ts": marker.ts,
3468        "callsign": marker.callsign,
3469        "color": marker.color,
3470        "cell_id": marker.cell_id,
3471    });
3472    if marker.deleted {
3473        v["_deleted"] = serde_json::Value::Bool(true);
3474    }
3475    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3476}
3477
3478fn serialize_node_json(node: &NodeInfo) -> Result<String, PeatError> {
3479    let v = serde_json::json!({
3480        "node_type": node.node_type,
3481        "name": node.name,
3482        "status": node.status.as_str(),
3483        "lat": node.lat,
3484        "lon": node.lon,
3485        "hae": node.hae,
3486        "readiness": node.readiness,
3487        "capabilities": node.capabilities,
3488        "cell_id": node.cell_id,
3489        "battery_percent": node.battery_percent,
3490        "heart_rate": node.heart_rate,
3491        "last_heartbeat": node.last_heartbeat,
3492    });
3493    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3494}
3495
3496fn parse_command_json(id: &str, json: &str) -> Result<CommandInfo, PeatError> {
3497    let root: serde_json::Value =
3498        serde_json::from_str(json).map_err(|e| PeatError::InvalidInput {
3499            msg: format!("Invalid JSON: {}", e),
3500        })?;
3501    // Docs published through the node layer are wrapped as {id, fields:{..}};
3502    // flat (legacy) writes keep fields at the root. Read from `fields` if present.
3503    let v = match root.get("fields") {
3504        Some(f) if f.is_object() => f,
3505        _ => &root,
3506    };
3507
3508    Ok(CommandInfo {
3509        id: id.to_string(),
3510        command_type: v["command_type"].as_str().unwrap_or("UNKNOWN").to_string(),
3511        target_id: v["target_id"].as_str().unwrap_or("").to_string(),
3512        parameters: v["parameters"].to_string(),
3513        priority: v["priority"].as_u64().unwrap_or(3) as u8,
3514        status: CommandStatus::from_str(v["status"].as_str().unwrap_or("PENDING")),
3515        originator: v["originator"].as_str().unwrap_or("").to_string(),
3516        created_at: v["created_at"].as_i64().unwrap_or(0),
3517        last_update: v["last_update"].as_i64().unwrap_or(0),
3518    })
3519}
3520
3521fn serialize_command_json(command: &CommandInfo) -> Result<String, PeatError> {
3522    // Parse parameters as JSON or use empty object
3523    let params: serde_json::Value =
3524        serde_json::from_str(&command.parameters).unwrap_or(serde_json::json!({}));
3525
3526    let v = serde_json::json!({
3527        "command_type": command.command_type,
3528        "target_id": command.target_id,
3529        "parameters": params,
3530        "priority": command.priority,
3531        "status": command.status.as_str(),
3532        "originator": command.originator,
3533        "created_at": command.created_at,
3534        "last_update": command.last_update,
3535    });
3536    serde_json::to_string(&v).map_err(|e| PeatError::EncodingError { msg: e.to_string() })
3537}
3538
3539#[cfg(test)]
3540mod tests {
3541    use super::*;
3542
3543    #[test]
3544    fn test_peat_version() {
3545        let version = peat_version();
3546        assert!(!version.is_empty());
3547        assert!(version.contains('.'));
3548    }
3549
3550    #[test]
3551    fn test_encode_track() {
3552        let track = TrackData {
3553            track_id: "track-001".to_string(),
3554            source_node: "node-1".to_string(),
3555            position: Position {
3556                lat: 34.0522,
3557                lon: -118.2437,
3558                hae: Some(100.0),
3559            },
3560            velocity: Some(Velocity {
3561                bearing: 90.0,
3562                speed_mps: 10.0,
3563            }),
3564            classification: "a-f-G-U-C".to_string(),
3565            confidence: 0.95,
3566            cell_id: Some("cell-1".to_string()),
3567            formation_id: None,
3568        };
3569
3570        let result = encode_track_to_cot(track);
3571        assert!(result.is_ok());
3572
3573        let xml = result.unwrap();
3574        assert!(xml.contains("<event"));
3575        assert!(xml.contains("track-001"));
3576    }
3577
3578    #[test]
3579    fn test_encode_minimal_track() {
3580        let track = TrackData {
3581            track_id: "t1".to_string(),
3582            source_node: "p1".to_string(),
3583            position: Position {
3584                lat: 0.0,
3585                lon: 0.0,
3586                hae: None,
3587            },
3588            velocity: None,
3589            classification: "a-u-G".to_string(),
3590            confidence: 0.5,
3591            cell_id: None,
3592            formation_id: None,
3593        };
3594
3595        let result = encode_track_to_cot(track);
3596        assert!(result.is_ok());
3597    }
3598
3599    #[test]
3600    fn test_invalid_track_id() {
3601        let track = TrackData {
3602            track_id: "".to_string(), // Empty - should fail
3603            source_node: "p1".to_string(),
3604            position: Position {
3605                lat: 0.0,
3606                lon: 0.0,
3607                hae: None,
3608            },
3609            velocity: None,
3610            classification: "a-u-G".to_string(),
3611            confidence: 0.5,
3612            cell_id: None,
3613            formation_id: None,
3614        };
3615
3616        let result = encode_track_to_cot(track);
3617        assert!(result.is_err());
3618    }
3619
3620    #[test]
3621    fn test_helper_functions() {
3622        let pos = create_position(34.0, -118.0, Some(50.0));
3623        assert_eq!(pos.lat, 34.0);
3624        assert_eq!(pos.lon, -118.0);
3625        assert_eq!(pos.hae, Some(50.0));
3626
3627        let vel = create_velocity(45.0, 15.0);
3628        assert_eq!(vel.bearing, 45.0);
3629        assert_eq!(vel.speed_mps, 15.0);
3630    }
3631
3632    /// Tests for the generic `publish_document_into_node` helper that backs
3633    /// `Java_..._publishDocumentJni`. Foundation step 3 of the
3634    /// peat-mesh-completion / peat-btle-reduction work — see
3635    /// `PEAT-MESH-COMPLETION-0.9.0.md`.
3636    ///
3637    /// Running through `tokio::runtime::Runtime::block_on` rather than a
3638    /// `#[tokio::test]` attribute matches the rest of peat-ffi (which doesn't
3639    /// pull tokio macros into dev-dependencies just for tests) and exercises
3640    /// the same `runtime.block_on(...)` shape the JNI wrapper itself uses.
3641    #[cfg(feature = "sync")]
3642    mod publish_document_tests {
3643        use super::*;
3644        use peat_mesh::sync::traits::DataSyncBackend;
3645        use peat_mesh::sync::InMemoryBackend;
3646
3647        fn fresh_node() -> peat_mesh::Node {
3648            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
3649            peat_mesh::Node::new(backend)
3650        }
3651
3652        fn rt() -> tokio::runtime::Runtime {
3653            tokio::runtime::Builder::new_current_thread()
3654                .enable_all()
3655                .build()
3656                .expect("runtime")
3657        }
3658
3659        /// Publishing a JSON object with an explicit `"id"` field round-trips
3660        /// through the node: the returned id matches, and `node.get(...)`
3661        /// yields a Document carrying the body fields verbatim.
3662        #[test]
3663        fn round_trip_with_explicit_id() {
3664            let rt = rt();
3665            rt.block_on(async {
3666                let node = fresh_node();
3667                let json = r#"{
3668                    "id": "chat-001",
3669                    "sender": "ALPHA-1",
3670                    "text": "hello",
3671                    "timestamp": 1700000000000
3672                }"#;
3673                let id = publish_document_into_node(&node, "chats", json)
3674                    .await
3675                    .expect("publish");
3676                assert_eq!(id, "chat-001");
3677
3678                let got = node
3679                    .get("chats", &"chat-001".to_string())
3680                    .await
3681                    .expect("get")
3682                    .expect("found");
3683                assert_eq!(
3684                    got.fields.get("sender").and_then(|v| v.as_str()),
3685                    Some("ALPHA-1")
3686                );
3687                assert_eq!(
3688                    got.fields.get("text").and_then(|v| v.as_str()),
3689                    Some("hello")
3690                );
3691                assert!(
3692                    !got.fields.contains_key("id"),
3693                    "id is hoisted to Document::id, not duplicated in fields"
3694                );
3695            });
3696        }
3697
3698        /// JSON without an `"id"` field still publishes; the backend assigns
3699        /// one (UUID under `InMemoryBackend`). The returned id is non-empty
3700        /// and the doc is retrievable by it.
3701        #[test]
3702        fn id_assignment_when_absent() {
3703            let rt = rt();
3704            rt.block_on(async {
3705                let node = fresh_node();
3706                let json = r#"{"text":"orphan","sender":"BRAVO-2"}"#;
3707                let id = publish_document_into_node(&node, "chats", json)
3708                    .await
3709                    .expect("publish");
3710                assert!(!id.is_empty(), "backend must assign an id");
3711
3712                let got = node.get("chats", &id).await.expect("get").expect("found");
3713                assert_eq!(
3714                    got.fields.get("text").and_then(|v| v.as_str()),
3715                    Some("orphan")
3716                );
3717            });
3718        }
3719
3720        /// Malformed JSON returns Err — the JNI wrapper translates this into
3721        /// an empty-string return to the Java caller.
3722        #[test]
3723        fn malformed_json_errors() {
3724            let rt = rt();
3725            rt.block_on(async {
3726                let node = fresh_node();
3727                let result = publish_document_into_node(&node, "chats", "not-json").await;
3728                assert!(result.is_err());
3729            });
3730        }
3731
3732        /// Non-object JSON (array, string, number) returns Err — the
3733        /// document model requires an object at the top level.
3734        #[test]
3735        fn non_object_json_errors() {
3736            let rt = rt();
3737            rt.block_on(async {
3738                let node = fresh_node();
3739                let result = publish_document_into_node(&node, "chats", "[1, 2, 3]").await;
3740                assert!(result.is_err());
3741            });
3742        }
3743
3744        /// Non-string id (e.g. integer) is treated as id-absent — the backend
3745        /// assigns one rather than coercing the integer. Aligns with
3746        /// peat-protocol's `value_to_mesh_document`, which made the same
3747        /// decision in PR #802 round-1 review.
3748        #[test]
3749        fn non_string_id_falls_back_to_assigned() {
3750            let rt = rt();
3751            rt.block_on(async {
3752                let node = fresh_node();
3753                let json = r#"{"id":42,"text":"weird"}"#;
3754                let id = publish_document_into_node(&node, "chats", json)
3755                    .await
3756                    .expect("publish");
3757                assert_ne!(id, "42", "non-string id must be discarded, not coerced");
3758                assert!(!id.is_empty());
3759            });
3760        }
3761
3762        /// Origin-aware variant publishes successfully and threads the
3763        /// origin string through to peat-mesh. ADR-059 Amendment 2 Slice
3764        /// 1.b.4 requires this so the plugin's `BleDecodedDocumentBridge`
3765        /// can ingest 0xB6 frames into the doc store without re-emitting
3766        /// them back out to BLE — `Some("ble")` triggers the same
3767        /// loop-prevention fan-out skip the existing `ingestPositionJni`
3768        /// path uses.
3769        #[test]
3770        fn origin_variant_publishes_with_explicit_id() {
3771            let rt = rt();
3772            rt.block_on(async {
3773                let node = fresh_node();
3774                let json = r#"{"id":"ble-decoded-001","sender":"OBS-1","text":"x"}"#;
3775                let id = publish_document_into_node_with_origin(
3776                    &node,
3777                    "chats",
3778                    json,
3779                    Some("ble".to_string()),
3780                )
3781                .await
3782                .expect("publish_with_origin");
3783                assert_eq!(id, "ble-decoded-001");
3784
3785                let got = node
3786                    .get("chats", &"ble-decoded-001".to_string())
3787                    .await
3788                    .expect("get")
3789                    .expect("found");
3790                assert_eq!(
3791                    got.fields.get("sender").and_then(|v| v.as_str()),
3792                    Some("OBS-1")
3793                );
3794            });
3795        }
3796
3797        /// `None` origin makes the helper behave identically to the plain
3798        /// publish path — locks the back-compat invariant the wrapper
3799        /// `publish_document_into_node` relies on.
3800        #[test]
3801        fn origin_variant_with_none_matches_plain_publish() {
3802            let rt = rt();
3803            rt.block_on(async {
3804                let node = fresh_node();
3805                let json = r#"{"id":"plain-001","text":"plain"}"#;
3806                let id = publish_document_into_node_with_origin(&node, "chats", json, None)
3807                    .await
3808                    .expect("publish_with_origin(None)");
3809                assert_eq!(id, "plain-001");
3810
3811                let got = node
3812                    .get("chats", &"plain-001".to_string())
3813                    .await
3814                    .expect("get")
3815                    .expect("found");
3816                assert_eq!(
3817                    got.fields.get("text").and_then(|v| v.as_str()),
3818                    Some("plain")
3819                );
3820            });
3821        }
3822    }
3823
3824    /// Tests for the BLE-translator helpers backing the `ingest*Jni`
3825    /// family. Slice 1.b.2.2 of ADR-059 — the inbound BLE→Node→iroh path
3826    /// now goes directly through `BleTranslator` + `Node::publish_with_origin`
3827    /// (the legacy `BleGateway` wrapper was deleted; its responsibilities
3828    /// composed in-line here).
3829    #[cfg(all(feature = "sync", feature = "bluetooth"))]
3830    mod ingest_position_tests {
3831        use super::*;
3832        use peat_mesh::sync::traits::DataSyncBackend;
3833        use peat_mesh::sync::InMemoryBackend;
3834        use peat_protocol::sync::ble_translation::BleTranslator;
3835
3836        struct Fixture {
3837            translator: BleTranslator,
3838            node: peat_mesh::Node,
3839        }
3840
3841        fn fresh_fixture() -> Fixture {
3842            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
3843            Fixture {
3844                translator: BleTranslator::with_defaults(),
3845                node: peat_mesh::Node::new(backend),
3846            }
3847        }
3848
3849        fn rt() -> tokio::runtime::Runtime {
3850            tokio::runtime::Builder::new_current_thread()
3851                .enable_all()
3852                .build()
3853                .expect("runtime")
3854        }
3855
3856        /// Happy path: a fully-populated JSON envelope ingests into the
3857        /// tracks collection, the returned id is the translator's
3858        /// BLE-prefixed track id (`ble-` + uppercase 8-hex peripheral id),
3859        /// and the resulting Document carries the position fields plus
3860        /// `ble_origin: true` so any outbound BLE re-encoder filtering
3861        /// on that marker breaks the loop.
3862        #[test]
3863        fn round_trip_full_envelope() {
3864            let rt = rt();
3865            rt.block_on(async {
3866                let fx = fresh_fixture();
3867                // peripheral_id 0xCAFE0001 = 3_405_643_777 — sanity-check the
3868                // hex form by using a constant rather than hand-converting.
3869                const PERIPHERAL: u32 = 0xCAFE_0001;
3870                let json = format!(
3871                    r#"{{
3872                        "lat": 40.7128,
3873                        "lon": -74.0060,
3874                        "altitude": 100.0,
3875                        "accuracy": 5.0,
3876                        "peripheral_id": {},
3877                        "callsign": "SCOUT-CAFE",
3878                        "mesh_id": "29C916FA"
3879                    }}"#,
3880                    PERIPHERAL
3881                );
3882                let id = ingest_position_via_translator(&fx.translator, &fx.node, &json)
3883                    .await
3884                    .expect("ingest");
3885                // Translator format: ble_id_prefix ("ble-") + uppercase 8-hex.
3886                assert_eq!(id, format!("ble-{:08X}", PERIPHERAL));
3887
3888                let doc = fx
3889                    .node
3890                    .get(fx.translator.tracks_collection(), &id)
3891                    .await
3892                    .expect("get")
3893                    .expect("found");
3894                assert_eq!(
3895                    doc.fields.get("ble_origin"),
3896                    Some(&serde_json::Value::Bool(true)),
3897                    "ble_origin marker required for outbound loop suppression"
3898                );
3899            });
3900        }
3901
3902        /// Optional fields can be omitted: altitude, accuracy, callsign,
3903        /// mesh_id all default to None and the ingest still succeeds.
3904        #[test]
3905        fn omits_optional_fields() {
3906            let rt = rt();
3907            rt.block_on(async {
3908                let fx = fresh_fixture();
3909                let json = r#"{
3910                    "lat": 40.7128,
3911                    "lon": -74.0060,
3912                    "peripheral_id": 1
3913                }"#;
3914                let id = ingest_position_via_translator(&fx.translator, &fx.node, json)
3915                    .await
3916                    .expect("ingest");
3917                assert_eq!(id, "ble-00000001");
3918            });
3919        }
3920
3921        /// Missing required fields (lat/lon/peripheral_id) error rather
3922        /// than silently defaulting. The JNI wrapper translates the Err
3923        /// into an empty-string Java return.
3924        #[test]
3925        fn missing_required_fields_errors() {
3926            let rt = rt();
3927            rt.block_on(async {
3928                let fx = fresh_fixture();
3929                let json_no_lat = r#"{"lon": -74.0, "peripheral_id": 1}"#;
3930                assert!(
3931                    ingest_position_via_translator(&fx.translator, &fx.node, json_no_lat)
3932                        .await
3933                        .is_err()
3934                );
3935
3936                let json_no_id = r#"{"lat": 40.0, "lon": -74.0}"#;
3937                assert!(
3938                    ingest_position_via_translator(&fx.translator, &fx.node, json_no_id)
3939                        .await
3940                        .is_err()
3941                );
3942            });
3943        }
3944
3945        /// Malformed JSON errors (matches the contract of the JNI wrapper).
3946        #[test]
3947        fn malformed_json_errors() {
3948            let rt = rt();
3949            rt.block_on(async {
3950                let fx = fresh_fixture();
3951                let result =
3952                    ingest_position_via_translator(&fx.translator, &fx.node, "not-json").await;
3953                assert!(result.is_err());
3954            });
3955        }
3956
3957        /// Regression for PR #804 round-1 [WARNING]: a Kotlin caller that
3958        /// serializes peripheral_id from a signed `Int` field (rather than
3959        /// `Long`/`UInt`) emits a negative JSON literal for any u32 with
3960        /// the high bit set. The parser must reinterpret-cast through i32
3961        /// to recover the original u32; the resulting track id must match
3962        /// what the same u32 written as a positive literal produced.
3963        #[test]
3964        fn peripheral_id_negative_int_form_recovers_to_same_u32() {
3965            let rt = rt();
3966            rt.block_on(async {
3967                let fx = fresh_fixture();
3968                // 0xCAFE_0001 = 3_405_643_777 as u32; -889_323_519 is the
3969                // sign-extended Int form (verified: (3_405_643_777_i64 -
3970                // 4_294_967_296) == -889_323_519).
3971                const POSITIVE: i64 = 3_405_643_777;
3972                const NEGATIVE: i64 = -889_323_519;
3973                let expected_id = "ble-CAFE0001";
3974
3975                let positive_json = format!(
3976                    r#"{{ "lat": 40.0, "lon": -74.0, "peripheral_id": {} }}"#,
3977                    POSITIVE
3978                );
3979                let negative_json = format!(
3980                    r#"{{ "lat": 40.0, "lon": -74.0, "peripheral_id": {} }}"#,
3981                    NEGATIVE
3982                );
3983
3984                let id_pos =
3985                    ingest_position_via_translator(&fx.translator, &fx.node, &positive_json)
3986                        .await
3987                        .expect("positive form ingests");
3988                assert_eq!(id_pos, expected_id);
3989
3990                let id_neg =
3991                    ingest_position_via_translator(&fx.translator, &fx.node, &negative_json)
3992                        .await
3993                        .expect("negative (Kotlin Int) form ingests");
3994                assert_eq!(
3995                    id_neg, expected_id,
3996                    "both forms must yield the same track id"
3997                );
3998            });
3999        }
4000
4001        /// Out-of-range values reject rather than silently truncate.
4002        /// Without bounds-checking, a >u32::MAX value would `as u32`
4003        /// truncate and collide distinct logical IDs onto the same
4004        /// translator-emitted track id, mis-attributing positions.
4005        #[test]
4006        fn peripheral_id_out_of_range_errors() {
4007            let rt = rt();
4008            rt.block_on(async {
4009                let fx = fresh_fixture();
4010
4011                // u32::MAX + 1
4012                let too_big = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": 4294967296 }"#;
4013                assert!(
4014                    ingest_position_via_translator(&fx.translator, &fx.node, too_big)
4015                        .await
4016                        .is_err()
4017                );
4018
4019                // i32::MIN - 1
4020                let too_small = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": -2147483649 }"#;
4021                assert!(
4022                    ingest_position_via_translator(&fx.translator, &fx.node, too_small)
4023                        .await
4024                        .is_err()
4025                );
4026            });
4027        }
4028
4029        /// u32::MAX and i32::MIN are valid boundaries. u32::MAX exercises
4030        /// the top of the positive form; i32::MIN exercises the top of the
4031        /// negative-Int form (a u32 with `high_bit=1, rest=0` =
4032        /// `0x8000_0000` = `-2_147_483_648` as Int).
4033        #[test]
4034        fn peripheral_id_boundaries_accepted() {
4035            let rt = rt();
4036            rt.block_on(async {
4037                let fx = fresh_fixture();
4038
4039                let max_json = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": 4294967295 }"#;
4040                let id = ingest_position_via_translator(&fx.translator, &fx.node, max_json)
4041                    .await
4042                    .expect("u32::MAX");
4043                assert_eq!(id, "ble-FFFFFFFF");
4044
4045                let min_int_json = r#"{ "lat": 40.0, "lon": -74.0, "peripheral_id": -2147483648 }"#;
4046                let id = ingest_position_via_translator(&fx.translator, &fx.node, min_int_json)
4047                    .await
4048                    .expect("i32::MIN as Int form");
4049                assert_eq!(id, "ble-80000000");
4050            });
4051        }
4052
4053        /// Slice 1.b.2.2: the rewire publishes through
4054        /// `Node::publish_with_origin(.., Some("ble"))`, so the resulting
4055        /// `ChangeEvent::Updated` must carry `origin = Some("ble")`. This
4056        /// is the load-bearing assertion that `TransportManager` fan-out
4057        /// can suppress the BLE→Node→observer→BLE same-node echo without
4058        /// it, the loop-break invariant is gone.
4059        #[tokio::test]
4060        async fn ingest_emits_observer_event_with_ble_origin() {
4061            use peat_mesh::sync::types::{ChangeEvent, Query};
4062            let fx = fresh_fixture();
4063            let mut tracks = fx
4064                .node
4065                .observe(fx.translator.tracks_collection(), &Query::All)
4066                .expect("observe");
4067
4068            let json = r#"{
4069                "lat": 40.7,
4070                "lon": -74.0,
4071                "peripheral_id": 1,
4072                "callsign": "SCOUT-1"
4073            }"#;
4074            let _ = ingest_position_via_translator(&fx.translator, &fx.node, json)
4075                .await
4076                .expect("ingest");
4077
4078            // Skip the Initial snapshot, then assert the Updated event's origin.
4079            loop {
4080                let ev = tracks.receiver.recv().await.expect("event");
4081                if let ChangeEvent::Updated { origin, .. } = ev {
4082                    assert_eq!(
4083                        origin,
4084                        Some("ble".to_string()),
4085                        "ingestPositionJni must publish with Some(\"ble\") origin per ADR-059"
4086                    );
4087                    break;
4088                }
4089            }
4090        }
4091    }
4092
4093    /// Tests for the outbound BLE-frame fan-out path (ADR-059 Slice 1.b.2).
4094    /// The JNI surface itself can't be exercised without a JVM, but the
4095    /// underlying mechanism — `TransportManager` registers a translator + sink,
4096    /// observer pushes through encode_outbound, sink receives bytes — is fully
4097    /// exercisable with a recording sink standing in for `JniOutboundSink`.
4098    #[cfg(all(feature = "sync", feature = "bluetooth"))]
4099    mod outbound_frame_tests {
4100        use super::*;
4101        use peat_mesh::sync::traits::DataSyncBackend;
4102        use peat_mesh::sync::InMemoryBackend;
4103        use peat_mesh::transport::{
4104            FanoutHandle, OutboundSink, TranslationContext, Translator,
4105            TranslatorRegistrationConfig,
4106        };
4107        use peat_protocol::sync::ble_translation::BleTranslator;
4108        use peat_protocol::transport::{TransportManager, TransportManagerConfig};
4109        use std::sync::Mutex as StdMutex;
4110        use tokio::time::{timeout, Duration};
4111
4112        /// Records `(transport_id, collection, bytes)` triples each time
4113        /// `send_outbound` fires. Stand-in for the JNI dispatcher in unit
4114        /// tests — we assert against the recorded frames rather than calling
4115        /// into a JVM.
4116        #[derive(Default)]
4117        struct RecordingSink {
4118            frames: StdMutex<Vec<(String, String, Vec<u8>)>>,
4119        }
4120
4121        #[async_trait::async_trait]
4122        impl OutboundSink for RecordingSink {
4123            async fn send_outbound(
4124                &self,
4125                bytes: Vec<u8>,
4126                ctx: &TranslationContext,
4127            ) -> anyhow::Result<()> {
4128                let collection = ctx.collection.clone().unwrap_or_default();
4129                self.frames
4130                    .lock()
4131                    .unwrap()
4132                    .push(("ble".to_string(), collection, bytes));
4133                Ok(())
4134            }
4135        }
4136
4137        impl RecordingSink {
4138            fn snapshot(&self) -> Vec<(String, String, Vec<u8>)> {
4139                self.frames.lock().unwrap().clone()
4140            }
4141        }
4142
4143        struct Fixture {
4144            node: Arc<peat_mesh::Node>,
4145            translator: Arc<BleTranslator>,
4146            transport_manager: TransportManager,
4147            sink: Arc<RecordingSink>,
4148        }
4149
4150        fn fixture() -> Fixture {
4151            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4152            Fixture {
4153                node: Arc::new(peat_mesh::Node::new(backend)),
4154                translator: Arc::new(BleTranslator::with_defaults()),
4155                transport_manager: TransportManager::new(TransportManagerConfig::default()),
4156                sink: Arc::new(RecordingSink::default()),
4157            }
4158        }
4159
4160        async fn register_and_start(fx: &Fixture) -> anyhow::Result<FanoutHandle> {
4161            let translator_dyn: Arc<dyn Translator> = fx.translator.clone();
4162            let sink_dyn: Arc<dyn OutboundSink> = fx.sink.clone();
4163            fx.transport_manager
4164                .register_translator(
4165                    translator_dyn,
4166                    sink_dyn,
4167                    TranslatorRegistrationConfig::ble(),
4168                )
4169                .await?;
4170            fx.transport_manager.start_fanout(
4171                Arc::clone(&fx.node),
4172                vec![fx.translator.tracks_collection().to_string()],
4173            )
4174        }
4175
4176        /// Wait up to 1s for the recording sink to receive at least
4177        /// `expected_count` frames. The fan-out is asynchronous (observer
4178        /// task → channel → drain task → sink), so a brief poll loop is
4179        /// the right shape — fixed sleeps would be flaky.
4180        async fn wait_for_frames(sink: &RecordingSink, expected: usize) {
4181            let _ = timeout(Duration::from_secs(1), async {
4182                loop {
4183                    if sink.snapshot().len() >= expected {
4184                        return;
4185                    }
4186                    tokio::time::sleep(Duration::from_millis(20)).await;
4187                }
4188            })
4189            .await;
4190        }
4191
4192        /// Baseline: a doc published via the iroh-side bridge (no
4193        /// `Some("ble")` origin) reaches the BLE sink — the
4194        /// translator-encode + drain-task path is wired correctly.
4195        #[tokio::test]
4196        async fn iroh_origin_doc_reaches_ble_sink() {
4197            let fx = fixture();
4198            let _h = register_and_start(&fx).await.expect("register");
4199
4200            // No origin = "iroh-side" doc. The fan-out should encode + deliver.
4201            let doc = peat_mesh::sync::types::Document::with_id("ble-00000001".to_string(), {
4202                let mut f = std::collections::HashMap::new();
4203                f.insert("lat".to_string(), serde_json::json!(40.0));
4204                f.insert("lon".to_string(), serde_json::json!(-74.0));
4205                f.insert(
4206                    "source_node".to_string(),
4207                    serde_json::json!("iroh-00000001"),
4208                );
4209                f.insert("hae".to_string(), serde_json::json!(100.0));
4210                f.insert("cep".to_string(), serde_json::json!(5.0));
4211                f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4212                f.insert("confidence".to_string(), serde_json::json!(0.9));
4213                f.insert("category".to_string(), serde_json::json!("friendly"));
4214                f.insert("callsign".to_string(), serde_json::json!("ALPHA-1"));
4215                f.insert(
4216                    "created_at".to_string(),
4217                    serde_json::json!(1_700_000_000_000_i64),
4218                );
4219                f.insert(
4220                    "last_update".to_string(),
4221                    serde_json::json!(1_700_000_000_000_i64),
4222                );
4223                f
4224            });
4225            fx.node.publish("tracks", doc).await.expect("publish");
4226
4227            wait_for_frames(&fx.sink, 1).await;
4228            let frames = fx.sink.snapshot();
4229            assert!(
4230                !frames.is_empty(),
4231                "iroh-origin track must reach ble sink; got 0 frames"
4232            );
4233            let (transport, collection, bytes) = &frames[0];
4234            assert_eq!(transport, "ble");
4235            assert_eq!(collection, "tracks");
4236            assert!(!bytes.is_empty(), "encoded bytes must be non-empty");
4237        }
4238
4239        /// Loop suppression: a doc with `origin = Some("ble")` (i.e.
4240        /// ingestPositionJni's output) MUST NOT be re-encoded back out the
4241        /// BLE sink. This is the same-node echo-loop break ADR-059 §
4242        /// "Origin propagation" requires.
4243        #[tokio::test]
4244        async fn ble_origin_doc_does_not_re_encode_to_ble_sink() {
4245            let fx = fixture();
4246            let _h = register_and_start(&fx).await.expect("register");
4247
4248            let doc = peat_mesh::sync::types::Document::with_id("ble-CAFE0001".to_string(), {
4249                let mut f = std::collections::HashMap::new();
4250                f.insert("lat".to_string(), serde_json::json!(40.0));
4251                f.insert("lon".to_string(), serde_json::json!(-74.0));
4252                f.insert("ble_origin".to_string(), serde_json::json!(true));
4253                f
4254            });
4255
4256            fx.node
4257                .publish_with_origin("tracks", doc, Some("ble".to_string()))
4258                .await
4259                .expect("publish");
4260
4261            // Hold the awaited window slightly past the steady-state
4262            // observer fan-out latency; if loop suppression is broken,
4263            // the sink would have received the encoded frame by now.
4264            tokio::time::sleep(Duration::from_millis(150)).await;
4265
4266            let frames = fx.sink.snapshot();
4267            assert!(
4268                frames.is_empty(),
4269                "ble-origin doc must be suppressed from outbound BLE \
4270                 (ADR-059 same-node echo break); got {} frames",
4271                frames.len()
4272            );
4273        }
4274
4275        /// Dropping the `FanoutHandle` (mirroring
4276        /// `unsubscribeOutboundFramesJni`'s teardown) stops further
4277        /// frames from reaching the sink.
4278        #[tokio::test]
4279        async fn drop_handle_stops_subsequent_delivery() {
4280            let fx = fixture();
4281            let h = register_and_start(&fx).await.expect("register");
4282
4283            // Sanity: first publish reaches sink.
4284            fx.node
4285                .publish(
4286                    "tracks",
4287                    peat_mesh::sync::types::Document::with_id("ble-00000001".to_string(), {
4288                        let mut f = std::collections::HashMap::new();
4289                        f.insert("lat".to_string(), serde_json::json!(40.0));
4290                        f.insert("lon".to_string(), serde_json::json!(-74.0));
4291                        f.insert("source_node".to_string(), serde_json::json!("iroh-1"));
4292                        f.insert("callsign".to_string(), serde_json::json!("A"));
4293                        f.insert("hae".to_string(), serde_json::json!(0.0));
4294                        f.insert("cep".to_string(), serde_json::json!(0.0));
4295                        f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4296                        f.insert("confidence".to_string(), serde_json::json!(0.5));
4297                        f.insert("category".to_string(), serde_json::json!("friendly"));
4298                        f.insert(
4299                            "created_at".to_string(),
4300                            serde_json::json!(1_700_000_000_000_i64),
4301                        );
4302                        f.insert(
4303                            "last_update".to_string(),
4304                            serde_json::json!(1_700_000_000_000_i64),
4305                        );
4306                        f
4307                    }),
4308                )
4309                .await
4310                .expect("publish-1");
4311            wait_for_frames(&fx.sink, 1).await;
4312            let pre_drop_count = fx.sink.snapshot().len();
4313            assert!(pre_drop_count >= 1);
4314
4315            // Drop the handle — observer tasks for this fan-out cancel.
4316            // The cancellation token is set synchronously on drop, but the
4317            // observer task only notices on its next `select!` poll, so we
4318            // yield+sleep briefly to let the runtime actually cancel the
4319            // task before producing the new broadcast. Without this gap,
4320            // tokio::select!'s non-biased polling may race the new event
4321            // ahead of the cancellation arm. (peat-mesh's observer_task
4322            // would benefit from `biased;` to make this deterministic;
4323            // tracked as a Slice 2 hardening item.)
4324            drop(h);
4325            tokio::time::sleep(Duration::from_millis(50)).await;
4326
4327            // Publish AFTER cancellation has settled. Use a distinct doc
4328            // id so any leaked frame would be visibly separate from
4329            // pre-drop traffic.
4330            fx.node
4331                .publish(
4332                    "tracks",
4333                    peat_mesh::sync::types::Document::with_id("ble-00000002".to_string(), {
4334                        let mut f = std::collections::HashMap::new();
4335                        f.insert("lat".to_string(), serde_json::json!(41.0));
4336                        f.insert("lon".to_string(), serde_json::json!(-75.0));
4337                        f.insert("source_node".to_string(), serde_json::json!("iroh-2"));
4338                        f.insert("callsign".to_string(), serde_json::json!("B"));
4339                        f.insert("hae".to_string(), serde_json::json!(0.0));
4340                        f.insert("cep".to_string(), serde_json::json!(0.0));
4341                        f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4342                        f.insert("confidence".to_string(), serde_json::json!(0.5));
4343                        f.insert("category".to_string(), serde_json::json!("friendly"));
4344                        f.insert(
4345                            "created_at".to_string(),
4346                            serde_json::json!(1_700_000_000_001_i64),
4347                        );
4348                        f.insert(
4349                            "last_update".to_string(),
4350                            serde_json::json!(1_700_000_000_001_i64),
4351                        );
4352                        f
4353                    }),
4354                )
4355                .await
4356                .expect("publish-2");
4357
4358            tokio::time::sleep(Duration::from_millis(200)).await;
4359
4360            let post_drop_count = fx.sink.snapshot().len();
4361            assert_eq!(
4362                post_drop_count, pre_drop_count,
4363                "no frames must arrive after FanoutHandle drop"
4364            );
4365        }
4366
4367        /// Re-register after teardown succeeds — the unsubscribe path is
4368        /// exercised against a clean slate. Mirrors the
4369        /// `unsubscribeOutboundFramesJni` → `subscribeOutboundFramesJni` JNI
4370        /// flow.
4371        #[tokio::test]
4372        async fn re_register_after_unregister_succeeds() {
4373            let fx = fixture();
4374            let h = register_and_start(&fx).await.expect("register-1");
4375            drop(h);
4376            fx.transport_manager
4377                .unregister_translator("ble")
4378                .await
4379                .expect("unregister");
4380
4381            // Second register must succeed (no transport_id collision).
4382            let _h2 = register_and_start(&fx).await.expect("register-2");
4383        }
4384
4385        /// Double-register on the same `transport_id` rejects with the
4386        /// ADR-059 §"Transport ID uniqueness" invariant. The JNI
4387        /// `subscribeOutboundFramesJni` defends against this by checking
4388        /// the FanoutHandle slot before re-registering — this test guards
4389        /// the underlying invariant the JNI relies on.
4390        #[tokio::test]
4391        async fn double_register_rejects() {
4392            let fx = fixture();
4393            let _h = register_and_start(&fx).await.expect("register-1");
4394            let result = register_and_start(&fx).await;
4395            assert!(
4396                result.is_err(),
4397                "second register on same transport_id must error"
4398            );
4399        }
4400
4401        // ----- Poll-API unit tests -----
4402
4403        /// `QueueOutboundSink::send_outbound` enqueues frames that can be
4404        /// drained via the queue directly — mirrors what `poll_outbound_frames`
4405        /// does at the `PeatNode` level.
4406        #[tokio::test]
4407        async fn queue_sink_enqueues_frames() {
4408            let queue = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
4409                OutboundFrame,
4410            >::new()));
4411            let sink = QueueOutboundSink {
4412                transport_id: "ble",
4413                queue: Arc::clone(&queue),
4414            };
4415            let ctx = TranslationContext::inbound("ble").with_collection("tracks");
4416            sink.send_outbound(vec![0xAA, 0xBB], &ctx).await.unwrap();
4417            sink.send_outbound(vec![0xCC], &ctx).await.unwrap();
4418
4419            let frames: Vec<OutboundFrame> = queue.lock().unwrap().drain(..).collect();
4420            assert_eq!(frames.len(), 2);
4421            assert_eq!(frames[0].transport_id, "ble");
4422            assert_eq!(frames[0].collection, "tracks");
4423            assert_eq!(frames[0].bytes, vec![0xAA, 0xBB]);
4424            assert_eq!(frames[1].bytes, vec![0xCC]);
4425        }
4426
4427        /// A document published via the fan-out path reaches the
4428        /// `QueueOutboundSink`, confirming the poll-API wiring matches the
4429        /// existing `RecordingSink`-based path. Mirrors
4430        /// `iroh_origin_doc_reaches_ble_sink`.
4431        #[tokio::test]
4432        async fn queue_sink_receives_fanned_out_doc() {
4433            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4434            let node = Arc::new(peat_mesh::Node::new(backend));
4435            let translator = Arc::new(BleTranslator::with_defaults());
4436            let tm = TransportManager::new(TransportManagerConfig::default());
4437            let queue = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
4438                OutboundFrame,
4439            >::new()));
4440            let sink: Arc<dyn OutboundSink> = Arc::new(QueueOutboundSink {
4441                transport_id: "ble",
4442                queue: Arc::clone(&queue),
4443            });
4444            let translator_dyn: Arc<dyn Translator> = translator.clone();
4445            tm.register_translator(translator_dyn, sink, TranslatorRegistrationConfig::ble())
4446                .await
4447                .expect("register");
4448            let _h = tm
4449                .start_fanout(
4450                    Arc::clone(&node),
4451                    vec![translator.tracks_collection().to_string()],
4452                )
4453                .expect("start_fanout");
4454
4455            let doc = peat_mesh::sync::types::Document::with_id("q-00000001".to_string(), {
4456                let mut f = std::collections::HashMap::new();
4457                f.insert("lat".to_string(), serde_json::json!(51.5));
4458                f.insert("lon".to_string(), serde_json::json!(-0.1));
4459                f.insert("source_platform".to_string(), serde_json::json!("iroh-q01"));
4460                f.insert("hae".to_string(), serde_json::json!(10.0));
4461                f.insert("cep".to_string(), serde_json::json!(2.0));
4462                f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4463                f.insert("confidence".to_string(), serde_json::json!(0.8));
4464                f.insert("category".to_string(), serde_json::json!("friendly"));
4465                f.insert("callsign".to_string(), serde_json::json!("BRAVO-1"));
4466                f.insert(
4467                    "created_at".to_string(),
4468                    serde_json::json!(1_700_000_001_000_i64),
4469                );
4470                f
4471            });
4472            node.publish(translator.tracks_collection(), doc)
4473                .await
4474                .expect("publish");
4475
4476            let _ = timeout(Duration::from_secs(1), async {
4477                loop {
4478                    if !queue.lock().unwrap().is_empty() {
4479                        return;
4480                    }
4481                    tokio::time::sleep(Duration::from_millis(20)).await;
4482                }
4483            })
4484            .await;
4485
4486            let frames: Vec<OutboundFrame> = queue.lock().unwrap().drain(..).collect();
4487            assert!(
4488                !frames.is_empty(),
4489                "queue sink must receive at least one frame"
4490            );
4491            assert_eq!(frames[0].transport_id, "ble");
4492            assert_eq!(frames[0].collection, translator.tracks_collection());
4493        }
4494
4495        /// `ingest_inbound_frame` round-trips: produce postcard bytes via
4496        /// `BleTranslator::encode_outbound` (the same path the real fan-out
4497        /// uses), then decode them back through `decode_inbound` and publish
4498        /// with `Some("ble")` origin (ADR-059 echo-suppression invariant).
4499        /// Tests the same primitives that `PeatNode::ingest_inbound_frame`
4500        /// uses.
4501        #[tokio::test]
4502        async fn ingest_inbound_frame_roundtrip_publishes_with_ble_origin() {
4503            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4504            let node = Arc::new(peat_mesh::Node::new(backend));
4505            let translator = Arc::new(BleTranslator::with_defaults());
4506
4507            // Build a minimal tracks document and encode it to postcard bytes.
4508            let outbound_doc =
4509                peat_mesh::sync::types::Document::with_id("enc-00000001".to_string(), {
4510                    let mut f = std::collections::HashMap::new();
4511                    f.insert("lat".to_string(), serde_json::json!(48.858));
4512                    f.insert("lon".to_string(), serde_json::json!(2.294));
4513                    f.insert(
4514                        "source_platform".to_string(),
4515                        serde_json::json!("iroh-enc01"),
4516                    );
4517                    f.insert("hae".to_string(), serde_json::json!(50.0));
4518                    f.insert("cep".to_string(), serde_json::json!(3.0));
4519                    f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4520                    f.insert("confidence".to_string(), serde_json::json!(0.9));
4521                    f.insert("category".to_string(), serde_json::json!("friendly"));
4522                    f.insert("callsign".to_string(), serde_json::json!("DELTA-1"));
4523                    f.insert(
4524                        "created_at".to_string(),
4525                        serde_json::json!(1_700_000_002_000_i64),
4526                    );
4527                    f
4528                });
4529            let encode_ctx = TranslationContext::inbound("ble")
4530                .with_collection(translator.tracks_collection().to_string());
4531            let postcard_bytes = translator
4532                .encode_outbound(&outbound_doc, &encode_ctx)
4533                .await
4534                .expect("encode_outbound should produce Some bytes for a tracks doc");
4535
4536            // Decode — mirrors what `ingest_inbound_frame` does.
4537            let decode_ctx = TranslationContext::inbound("ble")
4538                .with_collection(translator.tracks_collection().to_string());
4539            let decoded = translator
4540                .decode_inbound(&postcard_bytes, &decode_ctx)
4541                .await
4542                .expect("decode_inbound")
4543                .expect("should produce a document for tracks");
4544
4545            // Publish with ble origin so echo-suppression fires correctly.
4546            let id = node
4547                .publish_with_origin(
4548                    translator.tracks_collection(),
4549                    decoded,
4550                    Some("ble".to_string()),
4551                )
4552                .await
4553                .expect("publish");
4554
4555            // Verify the doc landed in the store.
4556            let stored = node
4557                .get(translator.tracks_collection(), &id)
4558                .await
4559                .expect("get")
4560                .expect("doc must be present after ingest");
4561            assert!(
4562                stored.fields.contains_key("lat"),
4563                "decoded document must contain lat field"
4564            );
4565        }
4566    }
4567
4568    /// Universal-Document path coexistence with the typed BLE path.
4569    /// Locks the load-bearing invariant for ADR-035 / ADR-059 Slice 1.b
4570    /// "scope #3": both translators register on the same physical wire
4571    /// under distinct transport_ids, the catch-all `LiteBridgeTranslator`
4572    /// is gated by `CollectionGatedLiteBridge` so it doesn't double-emit
4573    /// on the typed BleTranslator's collections, and origin-skip
4574    /// disambiguates each codec's emission independently.
4575    #[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
4576    mod lite_bridge_outbound_frame_tests {
4577        use super::*;
4578        use peat_mesh::sync::traits::DataSyncBackend;
4579        use peat_mesh::sync::InMemoryBackend;
4580        use peat_mesh::transport::{
4581            FanoutHandle, OutboundSink, TranslationContext, Translator,
4582            TranslatorRegistrationConfig, BLE_LITE_BRIDGE,
4583        };
4584        use peat_protocol::sync::ble_translation::BleTranslator;
4585        use peat_protocol::transport::{TransportManager, TransportManagerConfig};
4586        use std::sync::Mutex as StdMutex;
4587        use tokio::time::{timeout, Duration};
4588
4589        /// Like the typed-BLE `RecordingSink`, but stores its own
4590        /// transport_id so two parallel sinks can be told apart.
4591        struct TaggedRecordingSink {
4592            transport_id: &'static str,
4593            frames: StdMutex<Vec<(String, String, Vec<u8>)>>,
4594        }
4595
4596        #[async_trait::async_trait]
4597        impl OutboundSink for TaggedRecordingSink {
4598            async fn send_outbound(
4599                &self,
4600                bytes: Vec<u8>,
4601                ctx: &TranslationContext,
4602            ) -> anyhow::Result<()> {
4603                let collection = ctx.collection.clone().unwrap_or_default();
4604                self.frames.lock().unwrap().push((
4605                    self.transport_id.to_string(),
4606                    collection,
4607                    bytes,
4608                ));
4609                Ok(())
4610            }
4611        }
4612
4613        impl TaggedRecordingSink {
4614            fn new(transport_id: &'static str) -> Arc<Self> {
4615                Arc::new(Self {
4616                    transport_id,
4617                    frames: StdMutex::new(Vec::new()),
4618                })
4619            }
4620
4621            fn snapshot(&self) -> Vec<(String, String, Vec<u8>)> {
4622                self.frames.lock().unwrap().clone()
4623            }
4624        }
4625
4626        async fn wait_for_any(sinks: &[&Arc<TaggedRecordingSink>], min_total: usize) {
4627            let _ = timeout(Duration::from_secs(1), async {
4628                loop {
4629                    let total: usize = sinks.iter().map(|s| s.snapshot().len()).sum();
4630                    if total >= min_total {
4631                        return;
4632                    }
4633                    tokio::time::sleep(Duration::from_millis(20)).await;
4634                }
4635            })
4636            .await;
4637        }
4638
4639        struct CoexistenceFixture {
4640            node: Arc<peat_mesh::Node>,
4641            transport_manager: TransportManager,
4642            ble_sink: Arc<TaggedRecordingSink>,
4643            lite_sink: Arc<TaggedRecordingSink>,
4644        }
4645
4646        async fn coexistence_fixture() -> (CoexistenceFixture, FanoutHandle) {
4647            let backend: Arc<dyn DataSyncBackend> = Arc::new(InMemoryBackend::new_initialized());
4648            let node = Arc::new(peat_mesh::Node::new(backend));
4649            let mgr = TransportManager::new(TransportManagerConfig::default());
4650
4651            let ble_translator = Arc::new(BleTranslator::with_defaults());
4652            let ble_sink = TaggedRecordingSink::new("ble");
4653            let ble_translator_dyn: Arc<dyn Translator> = ble_translator.clone();
4654            let ble_sink_dyn: Arc<dyn OutboundSink> = ble_sink.clone();
4655            mgr.register_translator(
4656                ble_translator_dyn,
4657                ble_sink_dyn,
4658                TranslatorRegistrationConfig::ble(),
4659            )
4660            .await
4661            .expect("register typed BLE");
4662
4663            let lite_translator: Arc<dyn Translator> = Arc::new(
4664                CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS),
4665            );
4666            let lite_sink = TaggedRecordingSink::new(BLE_LITE_BRIDGE);
4667            let lite_sink_dyn: Arc<dyn OutboundSink> = lite_sink.clone();
4668            mgr.register_translator(
4669                lite_translator,
4670                lite_sink_dyn,
4671                TranslatorRegistrationConfig::ble(),
4672            )
4673            .await
4674            .expect("register lite-bridge");
4675
4676            // Observe both typed and universal-Document collections —
4677            // matches the production `subscribeOutboundFramesJni` shape.
4678            let mut collections = vec![
4679                ble_translator.tracks_collection().to_string(),
4680                ble_translator.nodes_collection().to_string(),
4681            ];
4682            for c in LITE_BRIDGE_COLLECTIONS {
4683                collections.push((*c).to_string());
4684            }
4685
4686            let handle = mgr
4687                .start_fanout(Arc::clone(&node), collections)
4688                .expect("start_fanout");
4689
4690            (
4691                CoexistenceFixture {
4692                    node,
4693                    transport_manager: mgr,
4694                    ble_sink,
4695                    lite_sink,
4696                },
4697                handle,
4698            )
4699        }
4700
4701        fn marker_doc(uuid: &str) -> peat_mesh::sync::types::Document {
4702            let mut fields = std::collections::HashMap::new();
4703            fields.insert("type".to_string(), serde_json::json!("a-f-G-U-C"));
4704            fields.insert("lat".to_string(), serde_json::json!(33.71));
4705            fields.insert("lon".to_string(), serde_json::json!(-84.41));
4706            peat_mesh::sync::types::Document::with_id(uuid.to_string(), fields)
4707        }
4708
4709        fn track_doc(uuid: &str) -> peat_mesh::sync::types::Document {
4710            // Minimum field set BleTranslator's track-encode requires.
4711            let mut f = std::collections::HashMap::new();
4712            f.insert("lat".to_string(), serde_json::json!(40.0));
4713            f.insert("lon".to_string(), serde_json::json!(-74.0));
4714            f.insert("source_node".to_string(), serde_json::json!("iroh-1"));
4715            f.insert("hae".to_string(), serde_json::json!(0.0));
4716            f.insert("cep".to_string(), serde_json::json!(0.0));
4717            f.insert("classification".to_string(), serde_json::json!("a-f-G-U-C"));
4718            f.insert("confidence".to_string(), serde_json::json!(0.5));
4719            f.insert("category".to_string(), serde_json::json!("friendly"));
4720            f.insert("callsign".to_string(), serde_json::json!("ALPHA-1"));
4721            f.insert(
4722                "created_at".to_string(),
4723                serde_json::json!(1_700_000_000_000_i64),
4724            );
4725            f.insert(
4726                "last_update".to_string(),
4727                serde_json::json!(1_700_000_000_000_i64),
4728            );
4729            peat_mesh::sync::types::Document::with_id(uuid.to_string(), f)
4730        }
4731
4732        /// A doc on `"markers"` (universal-Document collection) reaches
4733        /// the lite-bridge sink only — the typed BleTranslator declines
4734        /// the unknown collection silently, so the typed sink stays
4735        /// empty. The lite-bridge sink's bytes round-trip back through
4736        /// the codec to the original Document fields.
4737        #[tokio::test]
4738        async fn marker_publish_reaches_only_lite_bridge_sink() {
4739            let (fx, _h) = coexistence_fixture().await;
4740
4741            let doc = marker_doc("marker-uuid-001");
4742            let original_fields = doc.fields.clone();
4743            fx.node
4744                .publish_with_origin("markers", doc, Some("self".to_string()))
4745                .await
4746                .expect("publish marker");
4747
4748            wait_for_any(&[&fx.ble_sink, &fx.lite_sink], 1).await;
4749
4750            let ble_frames = fx.ble_sink.snapshot();
4751            let lite_frames = fx.lite_sink.snapshot();
4752
4753            assert!(
4754                ble_frames.is_empty(),
4755                "typed BLE sink MUST decline 'markers' (unknown collection); \
4756                 got {} frames",
4757                ble_frames.len()
4758            );
4759            assert_eq!(
4760                lite_frames.len(),
4761                1,
4762                "lite-bridge sink should see exactly one envelope for the marker"
4763            );
4764            let (transport_id, collection, bytes) = &lite_frames[0];
4765            assert_eq!(transport_id, BLE_LITE_BRIDGE);
4766            assert_eq!(collection, "markers");
4767
4768            // Round-trip the bytes back through the codec — proves the
4769            // wire frame is well-formed and reconstructs the original
4770            // Document fields.
4771            let (envelope_collection, decoded) =
4772                peat_mesh::transport::document_codec::decode_document(bytes)
4773                    .expect("decode envelope");
4774            assert_eq!(envelope_collection, "markers");
4775            assert_eq!(decoded.id.as_deref(), Some("marker-uuid-001"));
4776            assert_eq!(decoded.fields, original_fields);
4777        }
4778
4779        /// Tombstone variant of the markers-collection fanout path.
4780        /// A doc carrying `_deleted: true` on the `"markers"`
4781        /// collection must reach the lite-bridge sink with the
4782        /// sentinel preserved end-to-end. peat-mesh's fan-out skips
4783        /// `ChangeEvent::Removed` today (Slice-2 work); the soft-
4784        /// delete sentinel rides the Updated channel via this same
4785        /// path. If the codec drops the `_deleted` key in either
4786        /// direction, deletions never propagate and markers reappear
4787        /// on peers after every refresh — the failure mode that
4788        /// motivated this PR. Re-decoding the envelope bytes confirms
4789        /// the wire shape carries the flag.
4790        #[tokio::test]
4791        async fn marker_tombstone_publish_reaches_lite_bridge_sink_with_deleted_flag() {
4792            let (fx, _h) = coexistence_fixture().await;
4793
4794            let mut fields = std::collections::HashMap::new();
4795            fields.insert("_deleted".to_string(), serde_json::json!(true));
4796            fields.insert("ts".to_string(), serde_json::json!(1_700_000_000_000_i64));
4797            let doc = peat_mesh::sync::types::Document::with_id(
4798                "marker-tombstone-001".to_string(),
4799                fields.clone(),
4800            );
4801
4802            fx.node
4803                .publish_with_origin("markers", doc, Some("self".to_string()))
4804                .await
4805                .expect("publish tombstone");
4806
4807            wait_for_any(&[&fx.ble_sink, &fx.lite_sink], 1).await;
4808
4809            let ble_frames = fx.ble_sink.snapshot();
4810            let lite_frames = fx.lite_sink.snapshot();
4811            assert!(
4812                ble_frames.is_empty(),
4813                "typed BLE sink MUST decline 'markers' tombstone (unknown collection)"
4814            );
4815            assert_eq!(
4816                lite_frames.len(),
4817                1,
4818                "lite-bridge sink should see exactly one envelope for the tombstone"
4819            );
4820            let (_, collection, bytes) = &lite_frames[0];
4821            assert_eq!(collection, "markers");
4822
4823            let (envelope_collection, decoded) =
4824                peat_mesh::transport::document_codec::decode_document(bytes)
4825                    .expect("decode tombstone envelope");
4826            assert_eq!(envelope_collection, "markers");
4827            assert_eq!(decoded.id.as_deref(), Some("marker-tombstone-001"));
4828            assert_eq!(
4829                decoded.fields.get("_deleted"),
4830                Some(&serde_json::json!(true)),
4831                "tombstone _deleted: true must survive the BLE wire round-trip"
4832            );
4833        }
4834
4835        /// A doc on `"tracks"` (typed BLE collection) reaches the typed
4836        /// BLE sink only — the gating wrapper declines the
4837        /// non-allow-list collection, so the lite-bridge sink stays
4838        /// empty. This is the load-bearing assertion that the gate
4839        /// prevents double emission on typed-BLE collections.
4840        #[tokio::test]
4841        async fn track_publish_reaches_only_typed_ble_sink() {
4842            let (fx, _h) = coexistence_fixture().await;
4843
4844            let doc = track_doc("ble-CAFE0001");
4845            fx.node.publish("tracks", doc).await.expect("publish track");
4846
4847            wait_for_any(&[&fx.ble_sink, &fx.lite_sink], 1).await;
4848
4849            let ble_frames = fx.ble_sink.snapshot();
4850            let lite_frames = fx.lite_sink.snapshot();
4851
4852            assert_eq!(
4853                ble_frames.len(),
4854                1,
4855                "typed BLE sink should see the track frame"
4856            );
4857            assert!(
4858                lite_frames.is_empty(),
4859                "lite-bridge sink MUST decline 'tracks' (not in \
4860                 LITE_BRIDGE_COLLECTIONS allow-list); got {} frames",
4861                lite_frames.len()
4862            );
4863        }
4864
4865        /// Origin-skip is independent per codec: a marker published
4866        /// with `origin = Some(BLE_LITE_BRIDGE)` (i.e. just received
4867        /// from BLE via the universal-Document path) must NOT
4868        /// re-emit through the lite-bridge sink. The typed BLE sink is
4869        /// unaffected — it would have declined the unknown collection
4870        /// regardless.
4871        #[tokio::test]
4872        async fn ble_lite_origin_marker_does_not_re_emit_to_lite_bridge() {
4873            let (fx, _h) = coexistence_fixture().await;
4874
4875            // Skip-origin doc.
4876            let skip_doc = marker_doc("marker-skip");
4877            fx.node
4878                .publish_with_origin("markers", skip_doc, Some(BLE_LITE_BRIDGE.to_string()))
4879                .await
4880                .expect("publish skip");
4881
4882            // Barrier doc with non-skip origin — when this lands at the
4883            // lite-bridge sink we know the prior skip-origin doc was
4884            // already processed (and correctly suppressed) by the
4885            // FIFO observer.
4886            let barrier_doc = marker_doc("marker-barrier");
4887            fx.node
4888                .publish_with_origin("markers", barrier_doc, Some("self".to_string()))
4889                .await
4890                .expect("publish barrier");
4891
4892            wait_for_any(&[&fx.lite_sink], 1).await;
4893
4894            let lite_frames = fx.lite_sink.snapshot();
4895            assert_eq!(
4896                lite_frames.len(),
4897                1,
4898                "lite-bridge sink MUST receive only the barrier doc; \
4899                 the BLE_LITE_BRIDGE-origin doc must be suppressed by \
4900                 origin-skip (echo-loop break)"
4901            );
4902            // Confirm the captured doc is the barrier, not the
4903            // skip-origin one — defends against an inverted-skip bug.
4904            let bytes = &lite_frames[0].2;
4905            let (_collection, decoded) =
4906                peat_mesh::transport::document_codec::decode_document(bytes)
4907                    .expect("decode envelope");
4908            assert_eq!(decoded.id.as_deref(), Some("marker-barrier"));
4909        }
4910
4911        /// Re-register after teardown succeeds — both translators get
4912        /// torn down + re-registered cleanly. Mirrors the
4913        /// unsubscribe → subscribe JNI flow with the lite-bridge
4914        /// branch active.
4915        #[tokio::test]
4916        async fn re_register_with_lite_bridge_after_unregister_succeeds() {
4917            let (fx, h1) = coexistence_fixture().await;
4918            drop(h1);
4919            fx.transport_manager
4920                .unregister_translator(BLE_LITE_BRIDGE)
4921                .await
4922                .expect("unregister lite-bridge");
4923            fx.transport_manager
4924                .unregister_translator("ble")
4925                .await
4926                .expect("unregister typed BLE");
4927
4928            // Second register pass on the same TransportManager must
4929            // succeed (no transport_id collision left over).
4930            let ble_translator = Arc::new(BleTranslator::with_defaults());
4931            let ble_sink = TaggedRecordingSink::new("ble");
4932            let ble_translator_dyn: Arc<dyn Translator> = ble_translator.clone();
4933            let ble_sink_dyn: Arc<dyn OutboundSink> = ble_sink.clone();
4934            fx.transport_manager
4935                .register_translator(
4936                    ble_translator_dyn,
4937                    ble_sink_dyn,
4938                    TranslatorRegistrationConfig::ble(),
4939                )
4940                .await
4941                .expect("re-register typed BLE");
4942
4943            let lite_translator: Arc<dyn Translator> = Arc::new(
4944                CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS),
4945            );
4946            let lite_sink = TaggedRecordingSink::new(BLE_LITE_BRIDGE);
4947            let lite_sink_dyn: Arc<dyn OutboundSink> = lite_sink.clone();
4948            fx.transport_manager
4949                .register_translator(
4950                    lite_translator,
4951                    lite_sink_dyn,
4952                    TranslatorRegistrationConfig::ble(),
4953                )
4954                .await
4955                .expect("re-register lite-bridge");
4956        }
4957    }
4958
4959    /// Wrapper-tier E2E tests for the poll API added for Dart/Flutter
4960    /// consumers.
4961    ///
4962    /// These tests exercise the full path through the `PeatNode` wrapper —
4963    /// `subscribe_poll` / `poll_changes`, `start_outbound_frames` /
4964    /// `poll_outbound_frames` / `stop_outbound_frames`, and
4965    /// `ingest_inbound_frame` — using `create_node` as the entry point, the
4966    /// same way Flutter consumers do. Each test is intentionally independent
4967    /// (separate temp dirs, separate nodes) so failures are local.
4968    #[cfg(all(feature = "sync", feature = "bluetooth"))]
4969    mod poll_api_wrapper_tests {
4970        use super::*;
4971
4972        fn test_cfg(storage_path: &str) -> NodeConfig {
4973            NodeConfig {
4974                app_id: "poll-wrapper-test".to_string(),
4975                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
4976                bind_address: Some("127.0.0.1:0".to_string()),
4977                storage_path: storage_path.to_string(),
4978                transport: None,
4979            }
4980        }
4981
4982        /// `subscribe_poll` + `poll_changes` + `cancel` through the `PeatNode`
4983        /// wrapper.
4984        ///
4985        /// Creates a real node via `create_node`, subscribes with
4986        /// `subscribe_poll`, publishes a document via the mesh document
4987        /// layer (the path that actually
4988        /// triggers `subscribe_to_changes`), and verifies the change arrives
4989        /// through `poll_changes`. Also confirms the drain is
4990        /// idempotent and that `cancel` is safe to call multiple times.
4991        #[test]
4992        fn subscribe_poll_drain_and_cancel() {
4993            let tmp = tempfile::tempdir().unwrap();
4994            let node = create_node(test_cfg(tmp.path().to_str().unwrap())).expect("create_node");
4995
4996            let handle = node.subscribe_poll().expect("subscribe_poll");
4997
4998            // Publish through the mesh document layer — this feeds subscribe_to_changes().
4999            let mesh_node = Arc::clone(&node.node);
5000            node.runtime
5001                .block_on(publish_document_into_node(
5002                    &mesh_node,
5003                    "test",
5004                    r#"{"id":"doc-001","x":1}"#,
5005                ))
5006                .expect("publish_document_into_node");
5007
5008            // Give the spawned Tokio task time to pick up the broadcast.
5009            std::thread::sleep(std::time::Duration::from_millis(100));
5010
5011            let changes = handle.poll_changes();
5012            assert!(
5013                !changes.is_empty(),
5014                "poll_changes must return changes after publish_document_into_node"
5015            );
5016            assert!(
5017                changes.iter().any(|c| c.collection == "test"),
5018                "change must be for the 'test' collection; got: {changes:?}"
5019            );
5020
5021            // Drain is idempotent — second call returns nothing.
5022            assert!(
5023                handle.poll_changes().is_empty(),
5024                "second poll must be empty after drain"
5025            );
5026
5027            // cancel is safe to call repeatedly.
5028            handle.cancel();
5029            handle.cancel();
5030        }
5031
5032        /// `start_outbound_frames` → publish → `poll_outbound_frames` →
5033        /// `ingest_inbound_frame` → `stop_outbound_frames` → idempotent
5034        /// re-start.
5035        ///
5036        /// Covers the full wrapper path for the BLE poll API:
5037        /// - `start_outbound_frames` idempotency (second call is a no-op, not
5038        ///   an error)
5039        /// - A document published to "tracks" via the mesh layer produces an
5040        ///   outbound BLE frame visible through `poll_outbound_frames`
5041        /// - The polled frame can be fed into a second node via
5042        ///   `ingest_inbound_frame` and the decoded document appears in that
5043        ///   node's mesh store
5044        /// - `stop_outbound_frames` + `start_outbound_frames` re-registers the
5045        ///   translator without a duplicate-id collision
5046        #[test]
5047        fn outbound_frames_start_poll_ingest_stop_restart() {
5048            let tmp_a = tempfile::tempdir().unwrap();
5049            let tmp_b = tempfile::tempdir().unwrap();
5050            let node_a = create_node(test_cfg(tmp_a.path().to_str().unwrap())).expect("node_a");
5051            let node_b = create_node(test_cfg(tmp_b.path().to_str().unwrap())).expect("node_b");
5052
5053            // start is idempotent — second call must succeed, not error.
5054            node_a.start_outbound_frames().expect("start 1");
5055            node_a
5056                .start_outbound_frames()
5057                .expect("start 2 (idempotent no-op)");
5058
5059            // Publish a properly-structured tracks doc so BleTranslator can encode it.
5060            let tracks_json = r#"{
5061                "id": "track-wrap-001",
5062                "lat": 51.5, "lon": -0.1,
5063                "source_platform": "test-01",
5064                "hae": 10.0, "cep": 2.0,
5065                "classification": "a-f-G-U-C",
5066                "confidence": 0.9,
5067                "category": "friendly",
5068                "callsign": "ALPHA-1",
5069                "created_at": 1700000001000
5070            }"#;
5071            let mesh_a = Arc::clone(&node_a.node);
5072            node_a
5073                .runtime
5074                .block_on(publish_document_into_node(&mesh_a, "tracks", tracks_json))
5075                .expect("publish tracks");
5076
5077            // Poll with retries to allow the async fan-out observer to fire.
5078            let mut frames = Vec::new();
5079            for _ in 0..40 {
5080                frames = node_a.poll_outbound_frames();
5081                if !frames.is_empty() {
5082                    break;
5083                }
5084                std::thread::sleep(std::time::Duration::from_millis(25));
5085            }
5086            assert!(
5087                !frames.is_empty(),
5088                "outbound frames must appear after publishing to 'tracks'"
5089            );
5090            assert_eq!(frames[0].transport_id, "ble");
5091            assert_eq!(frames[0].collection, "tracks");
5092
5093            // Ingest on node_b — exercising the ingest_inbound_frame wrapper path.
5094            let doc_id = node_b
5095                .ingest_inbound_frame("tracks".to_string(), frames[0].bytes.clone())
5096                .expect("ingest_inbound_frame must not error")
5097                .expect("must return a doc_id for a valid tracks frame");
5098            assert!(!doc_id.is_empty(), "ingested doc_id must be non-empty");
5099
5100            // Document must be in node_b's mesh store.
5101            let stored = node_b
5102                .runtime
5103                .block_on(Arc::clone(&node_b.node).get("tracks", &doc_id))
5104                .expect("get must not error")
5105                .expect("ingested document must be in node_b's store");
5106            assert!(
5107                stored.fields.contains_key("lat"),
5108                "decoded track must carry lat field"
5109            );
5110
5111            // stop → re-start: translator must re-register without duplicate-id error.
5112            node_a.stop_outbound_frames();
5113            node_a
5114                .start_outbound_frames()
5115                .expect("re-start after stop must succeed");
5116            node_a.stop_outbound_frames(); // cleanup
5117        }
5118
5119        /// Receive-side counterpart for the universal-Document (`ble-lite`)
5120        /// codec — the path the production BLE pipe uses and that the typed
5121        /// `ingest_inbound_frame` test above does not exercise.
5122        ///
5123        /// Publishes to a `LITE_BRIDGE_COLLECTIONS` member the typed
5124        /// translator declines (`demo`), so it fans out solely as a `ble-lite`
5125        /// frame; captures that frame; ingests it on a second node via
5126        /// `PeatNode::ingest_inbound_lite_frame`; then asserts:
5127        /// (a) it converges into the receiver's store with the payload intact,
5128        /// (b) echo-suppression holds — the receiver does NOT re-emit it on
5129        ///     `ble-lite` (origin = `Some("ble-lite")` → fan-out skips the
5130        ///     originating transport). A regression here is the BLE echo storm.
5131        #[cfg(feature = "lite-bridge")]
5132        #[test]
5133        fn lite_outbound_poll_ingest_converges_without_echo() {
5134            let tmp_a = tempfile::tempdir().unwrap();
5135            let tmp_b = tempfile::tempdir().unwrap();
5136            let node_a = create_node(test_cfg(tmp_a.path().to_str().unwrap())).expect("node_a");
5137            let node_b = create_node(test_cfg(tmp_b.path().to_str().unwrap())).expect("node_b");
5138
5139            node_a.start_outbound_frames().expect("start a");
5140            node_b.start_outbound_frames().expect("start b");
5141
5142            // "demo" is on the lite-bridge allow-list AND declined by the typed
5143            // BleTranslator, so it fans out solely as a ble-lite frame.
5144            let demo_json = r#"{"id":"counter-demo-lite","inc":3,"dec":1,"by":"BRAVO"}"#;
5145            let mesh_a = Arc::clone(&node_a.node);
5146            node_a
5147                .runtime
5148                .block_on(publish_document_into_node(&mesh_a, "demo", demo_json))
5149                .expect("publish demo");
5150
5151            // Capture the ble-lite frame for the demo doc.
5152            let mut lite = None;
5153            for _ in 0..40 {
5154                if let Some(f) = node_a
5155                    .poll_outbound_frames()
5156                    .into_iter()
5157                    .find(|f| f.transport_id == "ble-lite" && f.collection == "demo")
5158                {
5159                    lite = Some(f);
5160                    break;
5161                }
5162                std::thread::sleep(std::time::Duration::from_millis(25));
5163            }
5164            let lite = lite.expect("a ble-lite frame must appear for the 'demo' doc");
5165
5166            // Drain anything node_b emitted before the ingest (expected: none).
5167            let _ = node_b.poll_outbound_frames();
5168
5169            // Ingest via the lite wrapper path on node_b.
5170            let doc_id = node_b
5171                .ingest_inbound_lite_frame("demo".to_string(), lite.bytes.clone())
5172                .expect("ingest_inbound_lite_frame must not error")
5173                .expect("must return a doc_id for a valid demo lite frame");
5174            assert!(!doc_id.is_empty(), "ingested doc_id must be non-empty");
5175
5176            // (a) Converged into node_b's store with the payload intact.
5177            let stored = node_b
5178                .runtime
5179                .block_on(Arc::clone(&node_b.node).get("demo", &doc_id))
5180                .expect("get must not error")
5181                .expect("ingested demo doc must be in node_b's store");
5182            assert_eq!(
5183                stored.fields.get("inc").and_then(|v| v.as_i64()),
5184                Some(3),
5185                "decoded demo doc must carry inc=3"
5186            );
5187            assert_eq!(
5188                stored.fields.get("by").and_then(|v| v.as_str()),
5189                Some("BRAVO"),
5190                "decoded demo doc must carry the 'by' field"
5191            );
5192
5193            // (b) Echo-suppression: node_b must NOT re-emit the just-ingested
5194            // doc on ble-lite. Any such frame in this window is the echo storm.
5195            let mut echoed = false;
5196            for _ in 0..16 {
5197                if node_b
5198                    .poll_outbound_frames()
5199                    .iter()
5200                    .any(|f| f.transport_id == "ble-lite" && f.collection == "demo")
5201                {
5202                    echoed = true;
5203                    break;
5204                }
5205                std::thread::sleep(std::time::Duration::from_millis(25));
5206            }
5207            assert!(
5208                !echoed,
5209                "ingested ble-lite doc must NOT be re-emitted on ble-lite \
5210                 (origin-skip / echo-suppression)"
5211            );
5212
5213            node_a.stop_outbound_frames();
5214            node_b.stop_outbound_frames();
5215        }
5216
5217        /// Direct coverage for the owning-handle store/clear semantics behind
5218        /// `set_global_node_handle` / `clearGlobalNodeHandleJni` (peat#978 UAF
5219        /// fix). Exercised against a LOCAL slot so it can't race the
5220        /// process-global `GLOBAL_NODE_HANDLE` other create-path tests touch.
5221        /// Asserts: store stashes a non-zero owning pointer (+1 strong ref);
5222        /// clear zeros the slot and drops exactly that one ref (no leak, no
5223        /// double-free).
5224        #[test]
5225        fn owning_node_slot_store_then_clear_drops_exactly_one_ref() {
5226            let tmp = tempfile::tempdir().unwrap();
5227            let node = create_node(test_cfg(tmp.path().to_str().unwrap())).expect("node");
5228            let slot = std::sync::Mutex::new(0i64);
5229
5230            let before = Arc::strong_count(&node);
5231            store_owning_node_in_slot(&slot, &node);
5232            assert_ne!(
5233                *slot.lock().unwrap(),
5234                0,
5235                "store must stash a non-zero owning pointer"
5236            );
5237            assert_eq!(
5238                Arc::strong_count(&node),
5239                before + 1,
5240                "store must add exactly one owning reference"
5241            );
5242
5243            clear_owning_node_slot(&slot);
5244            assert_eq!(*slot.lock().unwrap(), 0, "clear must zero the slot");
5245            assert_eq!(
5246                Arc::strong_count(&node),
5247                before,
5248                "clear must drop exactly the one stored reference (no leak/double-free)"
5249            );
5250        }
5251    }
5252
5253    /// Wrapped-vs-flat document-shape parsing (peat#978). Docs published
5254    /// through the node layer arrive wrapped as `{id, fields:{..},
5255    /// updated_at}`; legacy `storage_backend` writes are flat.
5256    /// `parse_node/cell/command_json` must read both shapes identically —
5257    /// the contract `LITE_BRIDGE_COLLECTIONS` now depends on for
5258    /// nodes/cells/commands to round-trip over BLE. The lite-bridge E2E
5259    /// test uses the flat `demo` shape, so it exercised only the
5260    /// fallback-to-root branch; these lock in the wrapped-`fields` branch.
5261    mod doc_shape_parse_tests {
5262        use super::*;
5263
5264        fn wrap(fields_json: &str) -> String {
5265            String::from(r#"{"id":"x","fields":"#)
5266                + fields_json
5267                + r#","updated_at":{"secs_since_epoch":1730000000,"nanos_since_epoch":0}}"#
5268        }
5269
5270        #[test]
5271        fn parse_node_json_wrapped_equals_flat() {
5272            let flat = r#"{"node_type":"peat-flutter","name":"Kilo","status":"ACTIVE","readiness":1.0,"capabilities":["comms","leader"],"last_heartbeat":1730000000000}"#;
5273            let a = parse_node_json("n1", flat).expect("flat parse");
5274            let b = parse_node_json("n1", &wrap(flat)).expect("wrapped parse");
5275            assert_eq!(
5276                b.name, "Kilo",
5277                "wrapped name must come from fields, not the id"
5278            );
5279            assert_eq!(b.name, a.name);
5280            assert_eq!(b.node_type, a.node_type);
5281            assert_eq!(b.capabilities, a.capabilities);
5282            assert_eq!(
5283                b.capabilities,
5284                vec!["comms".to_string(), "leader".to_string()]
5285            );
5286            assert_eq!(b.last_heartbeat, a.last_heartbeat);
5287            assert_eq!(b.last_heartbeat, 1730000000000);
5288        }
5289
5290        #[test]
5291        fn parse_cell_json_wrapped_equals_flat() {
5292            let flat = r#"{"name":"Alpha Cell","status":"ACTIVE","node_count":2,"capabilities":["comms"],"leader_id":"n1","last_update":1730000000000}"#;
5293            let a = parse_cell_json("alpha", flat).expect("flat parse");
5294            let b = parse_cell_json("alpha", &wrap(flat)).expect("wrapped parse");
5295            assert_eq!(b.name, "Alpha Cell");
5296            assert_eq!(b.node_count, 2);
5297            assert_eq!(b.node_count, a.node_count);
5298            assert_eq!(b.leader_id, a.leader_id);
5299            assert_eq!(b.capabilities, a.capabilities);
5300        }
5301
5302        #[test]
5303        fn parse_command_json_wrapped_equals_flat() {
5304            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}"#;
5305            let a = parse_command_json("req-1", flat).expect("flat parse");
5306            let b = parse_command_json("req-1", &wrap(flat)).expect("wrapped parse");
5307            assert_eq!(b.command_type, "WATER_REQUEST");
5308            assert_eq!(b.command_type, a.command_type);
5309            assert_eq!(b.originator, a.originator);
5310            assert_eq!(b.target_id, a.target_id);
5311            // parameters round-trips as the same JSON-object string in both shapes.
5312            assert_eq!(b.parameters, a.parameters);
5313        }
5314    }
5315
5316    #[cfg(feature = "sync")]
5317    mod blob_tests {
5318        use super::*;
5319
5320        /// Generate a synthetic test JPEG with a color gradient and a label.
5321        /// Synthetic "JPEG-like" payload for blob-transfer tests. Starts with
5322        /// the SOI marker (FF D8) and ends with EOI (FF D9) so the test
5323        /// assertions (`bytes[0]==0xFF`, `bytes[1]==0xD8`, `len > 100`,
5324        /// `len < 80_000`) all hold; the bytes in between are deterministic
5325        /// per (label, hue_shift) so each call produces a distinct blob
5326        /// hash. The blob-transfer path under test is byte-agnostic — using
5327        /// real JPEG encoding would pull the `image` crate's ~40 transitive
5328        /// dependencies into the workspace just for a synthetic test
5329        /// payload, which trips cargo-vet for no functional benefit.
5330        fn generate_test_image(label: &str, width: u32, height: u32, hue_shift: u8) -> Vec<u8> {
5331            let body_len = (width as usize * height as usize) / 4;
5332            let mut buf = Vec::with_capacity(body_len + label.len() + 8);
5333            buf.extend_from_slice(&[0xFF, 0xD8]); // SOI
5334            buf.extend_from_slice(label.as_bytes());
5335            buf.push(hue_shift);
5336            buf.extend(std::iter::repeat(hue_shift.wrapping_mul(3)).take(body_len));
5337            buf.extend_from_slice(&[0xFF, 0xD9]); // EOI
5338            buf
5339        }
5340
5341        fn test_node_config(storage_path: &str) -> NodeConfig {
5342            NodeConfig {
5343                app_id: "blob-test".to_string(),
5344                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5345                bind_address: Some("127.0.0.1:0".to_string()),
5346                storage_path: storage_path.to_string(),
5347                transport: None,
5348            }
5349        }
5350
5351        #[test]
5352        fn test_blob_put_get_local_roundtrip() {
5353            let tmp = tempfile::tempdir().unwrap();
5354            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5355                .expect("create_node failed");
5356
5357            node.enable_blob_transfer(None)
5358                .expect("enable_blob_transfer failed");
5359
5360            assert!(
5361                node.blob_endpoint_id().is_some(),
5362                "blob endpoint should be initialized"
5363            );
5364
5365            let test_data = b"SKUNK-1 image chip placeholder";
5366            let hash = node
5367                .blob_put(test_data, "image/jpeg")
5368                .expect("blob_put failed");
5369            assert!(!hash.is_empty(), "hash should be non-empty");
5370
5371            assert!(
5372                node.blob_exists_locally(&hash),
5373                "blob should exist locally after put"
5374            );
5375
5376            let retrieved = node.blob_get(&hash).expect("blob_get failed");
5377            assert_eq!(retrieved, test_data, "retrieved bytes must match original");
5378        }
5379
5380        #[test]
5381        fn test_blob_get_nonexistent_returns_error() {
5382            let tmp = tempfile::tempdir().unwrap();
5383            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5384                .expect("create_node failed");
5385
5386            node.enable_blob_transfer(None)
5387                .expect("enable_blob_transfer failed");
5388
5389            let fake_hash = "0000000000000000000000000000000000000000000000000000000000000000";
5390            assert!(
5391                !node.blob_exists_locally(fake_hash),
5392                "nonexistent hash should not be local"
5393            );
5394
5395            let result = node.blob_get(fake_hash);
5396            assert!(result.is_err(), "fetching nonexistent blob should error");
5397        }
5398
5399        #[test]
5400        fn test_blob_transfer_disabled_errors() {
5401            let tmp = tempfile::tempdir().unwrap();
5402            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5403                .expect("create_node failed");
5404
5405            // Don't call enable_blob_transfer — methods should return errors
5406            assert!(node.blob_endpoint_id().is_none());
5407            assert!(node.blob_put(b"data", "text/plain").is_err());
5408            assert!(node.blob_get("abc").is_err());
5409            assert!(!node.blob_exists_locally("abc"));
5410        }
5411
5412        #[test]
5413        fn test_blob_cross_node_transfer() {
5414            let tmp_a = tempfile::tempdir().unwrap();
5415            let tmp_b = tempfile::tempdir().unwrap();
5416
5417            let node_a = create_node(NodeConfig {
5418                app_id: "blob-xfer-test".to_string(),
5419                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5420                bind_address: Some("127.0.0.1:0".to_string()),
5421                storage_path: tmp_a.path().to_str().unwrap().to_string(),
5422                transport: None,
5423            })
5424            .expect("create node A");
5425
5426            let node_b = create_node(NodeConfig {
5427                app_id: "blob-xfer-test".to_string(),
5428                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5429                bind_address: Some("127.0.0.1:0".to_string()),
5430                storage_path: tmp_b.path().to_str().unwrap().to_string(),
5431                transport: None,
5432            })
5433            .expect("create node B");
5434
5435            // Enable blob transfer on both with ephemeral ports
5436            node_a
5437                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5438                .expect("enable blob A");
5439            node_b
5440                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5441                .expect("enable blob B");
5442
5443            let a_endpoint_id = node_a.blob_endpoint_id().expect("A blob endpoint");
5444            let a_addr = node_a.blob_bound_addr().expect("A bound addr");
5445
5446            // Register A as a blob peer on B
5447            node_b
5448                .blob_add_peer(&a_endpoint_id, &a_addr)
5449                .expect("add peer");
5450
5451            // Put blob on A
5452            let test_data = b"cross-node image chip test payload 1234567890";
5453            let hash = node_a.blob_put(test_data, "image/jpeg").expect("put on A");
5454
5455            // Fetch from B — should pull from A via iroh-blobs downloader
5456            let retrieved = node_b.blob_get(&hash).expect("get from B");
5457            assert_eq!(
5458                retrieved, test_data,
5459                "cross-node blob transfer: bytes must match"
5460            );
5461        }
5462
5463        #[test]
5464        fn test_e2e_contact_report_with_image_chip() {
5465            // End-to-end: sim node publishes a contact report (TrackUpdate)
5466            // with an embedded image chip blob hash. Tablet node syncs the
5467            // document and fetches the blob by hash. Validates the full
5468            // demo chain: mesh-leader → Iroh doc sync → tablet receives
5469            // track → tablet fetches image via blob transfer.
5470
5471            let tmp_sim = tempfile::tempdir().unwrap();
5472            let tmp_tablet = tempfile::tempdir().unwrap();
5473
5474            // Create sim node (mesh-leader stand-in)
5475            let sim = create_node(NodeConfig {
5476                app_id: "e2e-contact-test".to_string(),
5477                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5478                bind_address: Some("127.0.0.1:0".to_string()),
5479                storage_path: tmp_sim.path().to_str().unwrap().to_string(),
5480                transport: None,
5481            })
5482            .expect("create sim node");
5483
5484            // Create tablet node
5485            let tablet = create_node(NodeConfig {
5486                app_id: "e2e-contact-test".to_string(),
5487                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5488                bind_address: Some("127.0.0.1:0".to_string()),
5489                storage_path: tmp_tablet.path().to_str().unwrap().to_string(),
5490                transport: None,
5491            })
5492            .expect("create tablet node");
5493
5494            // Enable blob transfer on both
5495            sim.enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5496                .expect("sim blob");
5497            tablet
5498                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5499                .expect("tablet blob");
5500
5501            // Wire blob peers
5502            let sim_blob_id = sim.blob_endpoint_id().unwrap();
5503            let sim_blob_addr = sim.blob_bound_addr().unwrap();
5504            tablet
5505                .blob_add_peer(&sim_blob_id, &sim_blob_addr)
5506                .expect("tablet add sim as blob peer");
5507
5508            // Connect doc-sync peers so the track document propagates
5509            let sim_sync_id = sim.node_id();
5510            let sim_sync_addr = format!("{:?}", sim.iroh_transport.endpoint_addr());
5511            // For doc sync, connect tablet → sim via Iroh transport
5512            let sim_peer = PeerInfo {
5513                name: "sim".to_string(),
5514                node_id: sim_sync_id.clone(),
5515                addresses: vec![],
5516                relay_url: None,
5517            };
5518            // Use the runtime to connect
5519            let sim_clone = Arc::clone(&sim);
5520            let tablet_clone = Arc::clone(&tablet);
5521            tablet.runtime.block_on(async {
5522                tablet_clone
5523                    .iroh_transport
5524                    .connect_peer(&peat_protocol::network::PeerInfo {
5525                        name: "sim".to_string(),
5526                        node_id: sim_sync_id,
5527                        addresses: vec![sim_clone
5528                            .iroh_transport
5529                            .endpoint_addr()
5530                            .addrs
5531                            .iter()
5532                            .next()
5533                            .map(|a| format!("{}", a))
5534                            .unwrap_or_default()],
5535                        relay_url: None,
5536                    })
5537                    .await
5538                    .ok();
5539            });
5540
5541            // 1. Sim creates an image chip blob
5542            let fake_jpeg = b"\xFF\xD8\xFF\xE0fake-jpeg-contact-report-image-chip-data";
5543            let image_hash = sim.blob_put(fake_jpeg, "image/jpeg").expect("sim blob put");
5544
5545            // 2. Sim publishes a contact report (TrackUpdate) to the tracks collection
5546            let track_json = serde_json::json!({
5547                "id": "red-track-1",
5548                "source_node": "sensor-node-3",
5549                "source_model": "FLIR Vue Pro R 640",
5550                "model_version": "1.0",
5551                "cell_id": "company-CHARLIE",
5552                "lat": 32.655,
5553                "lon": -117.245,
5554                "heading": 0.0,
5555                "speed": 7.7,
5556                "classification": "a-h-S",
5557                "confidence": 0.82,
5558                "category": "VESSEL",
5559                "attributes": {
5560                    "callsign": "SKUNK-1",
5561                    "speed_kts": "15",
5562                    "vehicle_class": "fast attack craft",
5563                    "reporter": "sensor-node-3",
5564                    "distance_to_reporter_m": "800",
5565                    "image_chip_hash": &image_hash,
5566                },
5567                "last_update": std::time::SystemTime::now()
5568                    .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64,
5569            });
5570
5571            // Write to the tracks collection on the sim node
5572            let sim_backend = &sim.storage_backend;
5573            let tracks_coll = sim_backend.collection("tracks");
5574            tracks_coll
5575                .upsert("red-track-1", track_json.to_string().into_bytes())
5576                .expect("sim upsert track");
5577
5578            // 3. Wait for doc sync (give Iroh a moment to propagate)
5579            std::thread::sleep(std::time::Duration::from_secs(2));
5580
5581            // 4. Tablet reads the tracks collection
5582            let tablet_tracks = tablet_clone.storage_backend.collection("tracks");
5583            let track_doc = tablet_tracks.scan().expect("tablet scan tracks");
5584
5585            // The track may or may not have synced in 2s — this is the
5586            // realistic case. If it synced, validate the full chain.
5587            // If not, the blob transfer tests above already prove the
5588            // primitive works; this test extends coverage to the doc layer.
5589            if let Some((_id, data)) = track_doc.into_iter().find(|(id, _)| id == "red-track-1") {
5590                let parsed: serde_json::Value = serde_json::from_slice(&data).expect("valid JSON");
5591                assert_eq!(parsed["source_node"], "sensor-node-3");
5592                assert_eq!(parsed["classification"], "a-h-S");
5593                assert_eq!(parsed["attributes"]["callsign"], "SKUNK-1");
5594                assert_eq!(parsed["attributes"]["image_chip_hash"], image_hash);
5595
5596                // 5. Tablet fetches the image chip blob by hash
5597                let chip_hash = parsed["attributes"]["image_chip_hash"]
5598                    .as_str()
5599                    .expect("hash is string");
5600                let chip_bytes = tablet.blob_get(chip_hash).expect("tablet blob get");
5601                assert_eq!(
5602                    chip_bytes, fake_jpeg,
5603                    "image chip bytes must match across mesh"
5604                );
5605
5606                eprintln!("E2E PASS: contact report + image chip transferred through mesh");
5607            } else {
5608                // Doc sync didn't complete in 2s — not a failure of our code,
5609                // just Iroh mesh formation timing. The blob tests above prove
5610                // the primitive. Log and pass.
5611                eprintln!(
5612                    "E2E SKIP: doc sync didn't complete in 2s (blob transfer \
5613                     validated separately). Re-run if you want full chain coverage."
5614                );
5615            }
5616        }
5617
5618        #[test]
5619        fn test_blob_transfer_with_synthetic_image() {
5620            let tmp_a = tempfile::tempdir().unwrap();
5621            let tmp_b = tempfile::tempdir().unwrap();
5622
5623            let node_a = create_node(NodeConfig {
5624                app_id: "img-xfer-test".to_string(),
5625                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5626                bind_address: Some("127.0.0.1:0".to_string()),
5627                storage_path: tmp_a.path().to_str().unwrap().to_string(),
5628                transport: None,
5629            })
5630            .expect("create node A");
5631
5632            let node_b = create_node(NodeConfig {
5633                app_id: "img-xfer-test".to_string(),
5634                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5635                bind_address: Some("127.0.0.1:0".to_string()),
5636                storage_path: tmp_b.path().to_str().unwrap().to_string(),
5637                transport: None,
5638            })
5639            .expect("create node B");
5640
5641            node_a
5642                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5643                .expect("enable A");
5644            node_b
5645                .enable_blob_transfer(Some("127.0.0.1:0".parse().unwrap()))
5646                .expect("enable B");
5647
5648            let a_id = node_a.blob_endpoint_id().unwrap();
5649            let a_addr = node_a.blob_bound_addr().unwrap();
5650            node_b.blob_add_peer(&a_id, &a_addr).expect("add peer");
5651
5652            // Generate 4 keyframe images (matching the demo's progression stages)
5653            let images = vec![
5654                (
5655                    "distant",
5656                    generate_test_image("SKUNK-1 DISTANT", 160, 120, 40),
5657                ),
5658                (
5659                    "approach",
5660                    generate_test_image("SKUNK-1 APPROACH", 160, 120, 80),
5661                ),
5662                ("close", generate_test_image("SKUNK-1 CLOSE", 160, 120, 160)),
5663                ("id", generate_test_image("SKUNK-1 ID", 160, 120, 220)),
5664            ];
5665
5666            for (label, jpeg_bytes) in &images {
5667                assert!(jpeg_bytes.len() > 100, "{} should be a real JPEG", label);
5668                assert!(
5669                    jpeg_bytes.len() < 80_000,
5670                    "{} should be under 80KB (got {})",
5671                    label,
5672                    jpeg_bytes.len()
5673                );
5674                // JPEG magic bytes
5675                assert_eq!(jpeg_bytes[0], 0xFF);
5676                assert_eq!(jpeg_bytes[1], 0xD8);
5677            }
5678
5679            // Put all 4 on node A, fetch from node B
5680            let mut hashes = Vec::new();
5681            for (label, jpeg_bytes) in &images {
5682                let hash = node_a
5683                    .blob_put(jpeg_bytes, "image/jpeg")
5684                    .unwrap_or_else(|e| panic!("put {label}: {e}"));
5685                hashes.push((label.to_string(), hash));
5686            }
5687
5688            for (label, hash) in &hashes {
5689                let fetched = node_b
5690                    .blob_get(hash)
5691                    .unwrap_or_else(|e| panic!("get {label}: {e}"));
5692                let original = &images.iter().find(|(l, _)| l == label).unwrap().1;
5693                assert_eq!(
5694                    fetched.len(),
5695                    original.len(),
5696                    "{}: fetched size must match",
5697                    label
5698                );
5699                assert_eq!(
5700                    fetched, *original,
5701                    "{}: fetched bytes must match original",
5702                    label
5703                );
5704            }
5705
5706            eprintln!(
5707                "IMAGE TRANSFER PASS: 4 synthetic JPEGs transferred cross-node ({} total bytes)",
5708                images.iter().map(|(_, b)| b.len()).sum::<usize>()
5709            );
5710        }
5711    }
5712
5713    /// Surface-tier tests for the two new public entry points added
5714    /// for peat-mesh#138 M4 (peat#879): `PeatNode::endpoint_socket_addr`
5715    /// and `PeatNode::get_document`. Both are wrapped by JNI symbols
5716    /// (`endpointSocketAddrJni`, `getDocumentJni`) that the two-
5717    /// instance instrumented test suite in peat-mesh/android-tests
5718    /// will consume in M4b. Per the surface-tier E2E rule these need
5719    /// in-crate tests independent of that downstream consumer.
5720    #[cfg(feature = "sync")]
5721    mod m4_endpoint_and_get_document_tests {
5722        use super::*;
5723
5724        fn test_node_config(storage_path: &str) -> NodeConfig {
5725            NodeConfig {
5726                app_id: "m4-test".to_string(),
5727                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
5728                bind_address: Some("127.0.0.1:0".to_string()),
5729                storage_path: storage_path.to_string(),
5730                transport: None,
5731            }
5732        }
5733
5734        /// `endpoint_socket_addr` on a freshly-bound node returns a
5735        /// string that round-trips through `SocketAddr::parse` and
5736        /// carries a non-zero port. This is the contract M4b's
5737        /// instrumented test relies on when it feeds the returned
5738        /// string back into `connectPeerJni` on the other instance.
5739        #[test]
5740        fn endpoint_socket_addr_returns_parseable_loopback_addr() {
5741            let tmp = tempfile::tempdir().unwrap();
5742            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5743                .expect("create_node failed");
5744
5745            let addr_str = node
5746                .endpoint_socket_addr()
5747                .expect("a bound node must report at least one IP address");
5748
5749            let parsed: std::net::SocketAddr = addr_str.parse().unwrap_or_else(|e| {
5750                panic!("endpoint_socket_addr returned '{addr_str}' which doesn't parse as SocketAddr: {e}")
5751            });
5752            assert!(
5753                parsed.port() > 0,
5754                "port must be nonzero for a bound socket, got {parsed}"
5755            );
5756        }
5757
5758        /// Publish a doc through the document layer, then read it
5759        /// back through the same layer. Locks in the round-trip
5760        /// contract that `publishDocumentJni` + `getDocumentJni`
5761        /// expose: both go through `peat_mesh::Node`'s document API,
5762        /// not the older raw-bytes Collection path used by typed
5763        /// helpers like `publish_node`.
5764        ///
5765        /// The in-process variant locks in the publish+get half on a
5766        /// single instance; cross-node sync is exercised by M4b on
5767        /// real devices in peat-mesh/android-tests.
5768        #[test]
5769        fn document_layer_round_trip_publish_then_get() {
5770            let tmp = tempfile::tempdir().unwrap();
5771            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5772                .expect("create_node failed");
5773
5774            let collection = "markers";
5775            let doc_id = "M-RT-1";
5776            let body = format!(r#"{{"id":"{doc_id}","name":"alpha","severity":3}}"#);
5777
5778            let mesh_node = Arc::clone(&node.node);
5779            let returned_id = node
5780                .runtime
5781                .block_on(publish_document_into_node(&mesh_node, collection, &body))
5782                .expect("publish_document_into_node");
5783            assert_eq!(returned_id, doc_id);
5784
5785            let fetched = node
5786                .runtime
5787                .block_on(mesh_node.get(collection, &doc_id.to_string()))
5788                .expect("get must not Err")
5789                .expect("doc must be present on the publishing node");
5790
5791            // Body content must round-trip; assert on the two fields
5792            // M4b's Kotlin test pins. The published id is hoisted to
5793            // Document::id; assert separately.
5794            assert_eq!(
5795                fetched.id.as_deref(),
5796                Some(doc_id),
5797                "published id must round-trip through Document::id"
5798            );
5799            assert_eq!(
5800                fetched.fields.get("name").and_then(|v| v.as_str()),
5801                Some("alpha")
5802            );
5803            assert_eq!(
5804                fetched.fields.get("severity").and_then(|v| v.as_i64()),
5805                Some(3)
5806            );
5807        }
5808
5809        /// Surface-tier coverage for `getDocumentJni`'s JSON
5810        /// serialization path (peat#879 QA round 2). The struct-
5811        /// level round-trip test above exercises storage; this one
5812        /// exercises the extracted `serialize_document_for_get_jni`
5813        /// helper that produces the exact bytes the JNI returns —
5814        /// covering the id-reinsertion, field-iteration, and
5815        /// `to_string()` encoding the QA reviewer flagged as
5816        /// untested.
5817        #[test]
5818        fn jni_serializer_reinserts_id_alongside_fields() {
5819            // Publish through the same path the JNI consumer takes,
5820            // read back via Node::get, then run the JNI's serializer
5821            // and assert on the JSON the consumer would actually see.
5822            let tmp = tempfile::tempdir().unwrap();
5823            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5824                .expect("create_node failed");
5825
5826            let collection = "markers";
5827            let doc_id = "M-RT-1";
5828            let body = format!(r#"{{"id":"{doc_id}","name":"alpha","severity":3}}"#);
5829
5830            let mesh_node = Arc::clone(&node.node);
5831            let _ = node
5832                .runtime
5833                .block_on(publish_document_into_node(&mesh_node, collection, &body))
5834                .expect("publish");
5835
5836            let fetched = node
5837                .runtime
5838                .block_on(mesh_node.get(collection, &doc_id.to_string()))
5839                .expect("get must not Err")
5840                .expect("doc must be present");
5841
5842            // Serialize via the exact helper getDocumentJni uses.
5843            let json = serialize_document_for_get_jni(&fetched);
5844            let parsed: serde_json::Value =
5845                serde_json::from_str(&json).expect("JNI output must parse as JSON");
5846
5847            // The Kotlin consumer expects: a plain object with id +
5848            // every other field. Pin each field shape including the
5849            // reinserted id (the QA-flagged regression surface).
5850            assert!(
5851                parsed.is_object(),
5852                "output must be a JSON object, got {parsed:?}"
5853            );
5854            assert_eq!(parsed["id"], doc_id, "id must be reinserted");
5855            assert_eq!(parsed["name"], "alpha");
5856            assert_eq!(parsed["severity"], 3);
5857            // Field count: id + name + severity — no extras.
5858            assert_eq!(
5859                parsed.as_object().unwrap().len(),
5860                3,
5861                "unexpected extra fields in JNI serialization: {parsed}"
5862            );
5863        }
5864
5865        /// Boundary: a Document with no `id` (a write path that
5866        /// didn't go through publish-with-explicit-id) serializes
5867        /// without an `"id"` key — never as `"id": null`. This
5868        /// matches the consumer contract that `id` is present iff
5869        /// the document had one assigned.
5870        #[test]
5871        fn jni_serializer_omits_id_when_none() {
5872            let doc = peat_mesh::sync::Document {
5873                id: None,
5874                fields: {
5875                    let mut m = std::collections::HashMap::new();
5876                    m.insert("k".to_string(), serde_json::Value::String("v".into()));
5877                    m
5878                },
5879                updated_at: std::time::SystemTime::now(),
5880            };
5881
5882            let json = serialize_document_for_get_jni(&doc);
5883            let parsed: serde_json::Value = serde_json::from_str(&json).expect("parseable JSON");
5884
5885            assert!(
5886                parsed.get("id").is_none(),
5887                "expected id absent (not null) when Document::id is None, got {json}"
5888            );
5889            assert_eq!(parsed["k"], "v");
5890        }
5891
5892        /// `peat_mesh::Node::get` on a never-published key returns
5893        /// `Ok(None)`. The `getDocumentJni` wrapper maps this to a
5894        /// null jstring — test-readable as "not yet converged"
5895        /// rather than "store failed". Symmetry with
5896        /// `document_layer_round_trip_publish_then_get`.
5897        #[test]
5898        fn document_layer_get_returns_none_for_missing_doc() {
5899            let tmp = tempfile::tempdir().unwrap();
5900            let node = create_node(test_node_config(tmp.path().to_str().unwrap()))
5901                .expect("create_node failed");
5902
5903            let mesh_node = Arc::clone(&node.node);
5904            let result = node
5905                .runtime
5906                .block_on(mesh_node.get("markers", &"never-published".to_string()))
5907                .expect("get must not Err");
5908            assert!(
5909                result.is_none(),
5910                "expected None for a never-published doc, got {result:?}"
5911            );
5912        }
5913    }
5914
5915    /// Round-trip tests for the `NodeInfo` JSON wire schema.
5916    ///
5917    /// Locks in the symmetry contract between `parse_node_json`
5918    /// (storage → struct) and `serialize_node_json` (struct →
5919    /// storage), and the parallel JNI inline encode/decode in
5920    /// `Java_..._publishNodeJni` / `Java_..._getNodesJni`. The
5921    /// pre-2026-05-08 schema dropped `battery_percent` and `heart_rate`
5922    /// silently across the FFI boundary: Kotlin published them, Rust
5923    /// didn't extract them, the receiver's `getNodesJni` didn't
5924    /// emit them, the Kotlin parser saw them as `null`, and operator
5925    /// cards on remote peers showed no battery/heart indicators.
5926    /// Without a Rust-side test the bug compile-cleaned and only
5927    /// surfaced via three-device on-hardware UAT. Each assertion below
5928    /// corresponds to one optional field; future schema additions
5929    /// should add a parallel assertion + bump
5930    /// `every_optional_field_round_trips_through_storage` so the
5931    /// matrix stays exhaustive.
5932    #[cfg(feature = "sync")]
5933    mod node_tests {
5934        use super::*;
5935
5936        fn fixture(battery: Option<i32>, heart: Option<i32>) -> NodeInfo {
5937            NodeInfo {
5938                id: "ANDROID-fixture".to_string(),
5939                node_type: "SOLDIER".to_string(),
5940                name: "HOBO".to_string(),
5941                status: NodeStatus::Active,
5942                lat: 33.71576,
5943                lon: -84.41152,
5944                hae: Some(305.0),
5945                readiness: 1.0,
5946                capabilities: vec!["PLI".to_string()],
5947                cell_id: Some("BRAVO".to_string()),
5948                battery_percent: battery,
5949                heart_rate: heart,
5950                last_heartbeat: 1_700_000_000_000,
5951            }
5952        }
5953
5954        /// `serialize_node_json` → `parse_node_json` is the
5955        /// path `put_node` / `get_nodes` traverse via the
5956        /// AutomergeBackend storage. Every field a `NodeInfo`
5957        /// carries today must round-trip; if a future field is added
5958        /// to the struct without being added to either codec function,
5959        /// this assertion catches it before the FFI consumer does.
5960        #[test]
5961        fn every_optional_field_round_trips_through_storage_codec() {
5962            let original = fixture(Some(85), Some(72));
5963            let json = serialize_node_json(&original).expect("serialize");
5964            let parsed = parse_node_json(&original.id, &json).expect("parse");
5965
5966            assert_eq!(parsed.id, original.id);
5967            assert_eq!(parsed.node_type, original.node_type);
5968            assert_eq!(parsed.name, original.name);
5969            assert_eq!(parsed.lat, original.lat);
5970            assert_eq!(parsed.lon, original.lon);
5971            assert_eq!(parsed.hae, original.hae);
5972            assert_eq!(parsed.readiness, original.readiness);
5973            assert_eq!(parsed.capabilities, original.capabilities);
5974            assert_eq!(parsed.cell_id, original.cell_id);
5975            assert_eq!(parsed.battery_percent, original.battery_percent);
5976            assert_eq!(parsed.heart_rate, original.heart_rate);
5977            assert_eq!(parsed.last_heartbeat, original.last_heartbeat);
5978        }
5979
5980        /// `battery_percent: None` must serialize to a JSON `null` (or
5981        /// absent) and parse back to `None` — not silently fill 0,
5982        /// which the dropdown UI would render as "battery dead" on
5983        /// nodes that simply have no battery sensor (fixed
5984        /// sensors, demo nodes).
5985        #[test]
5986        fn battery_none_round_trips_as_none() {
5987            let original = fixture(None, None);
5988            let json = serialize_node_json(&original).expect("serialize");
5989            let parsed = parse_node_json(&original.id, &json).expect("parse");
5990
5991            assert!(parsed.battery_percent.is_none());
5992            assert!(parsed.heart_rate.is_none());
5993        }
5994
5995        /// Schema is forward-compatible: a JSON written by a newer
5996        /// peer that adds a field we don't know yet must still parse,
5997        /// dropping the unknown key. Conversely, a JSON written by an
5998        /// older peer that lacks `battery_percent` / `heart_rate`
5999        /// must parse with those fields as `None` rather than failing.
6000        #[test]
6001        fn legacy_json_without_battery_or_heart_parses_with_none() {
6002            let legacy_json = serde_json::json!({
6003                "node_type": "SOLDIER",
6004                "name": "LEGACY-PEER",
6005                "status": "ACTIVE",
6006                "lat": 33.71,
6007                "lon": -84.41,
6008                "hae": null,
6009                "readiness": 1.0,
6010                "capabilities": ["PLI"],
6011                "cell_id": "BRAVO",
6012                "last_heartbeat": 1_700_000_000_000_i64,
6013            })
6014            .to_string();
6015
6016            let parsed =
6017                parse_node_json("LEGACY-PEER", &legacy_json).expect("legacy json must parse");
6018
6019            assert!(parsed.battery_percent.is_none());
6020            assert!(parsed.heart_rate.is_none());
6021            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6022        }
6023
6024        /// `put_node` → `get_nodes` is the actual storage
6025        /// path the JNI layer exposes. Bypassing the codec helpers
6026        /// and going through `node.put_node(...)` exercises the
6027        /// AutomergeBackend serialize/scan/deserialize loop end-to-end
6028        /// — which is exactly where peat#832 (BLE-bridged tracks
6029        /// losing body fields) demonstrated the codec helpers can
6030        /// look correct in isolation while still dropping data
6031        /// across the storage round-trip.
6032        #[test]
6033        fn put_node_get_nodes_preserves_battery_and_heart() {
6034            let tmp = tempfile::tempdir().unwrap();
6035            let node = create_node(NodeConfig {
6036                app_id: "node-rt-test".to_string(),
6037                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
6038                bind_address: Some("127.0.0.1:0".to_string()),
6039                storage_path: tmp.path().to_str().unwrap().to_string(),
6040                transport: None,
6041            })
6042            .expect("create_node");
6043
6044            let original = fixture(Some(85), Some(72));
6045            node.put_node(original.clone()).expect("put_node");
6046
6047            let listed = node.get_nodes().expect("get_nodes");
6048            let found = listed
6049                .iter()
6050                .find(|p| p.id == original.id)
6051                .expect("published node must appear in get_nodes");
6052
6053            assert_eq!(
6054                found.battery_percent,
6055                Some(85),
6056                "battery_percent dropped between put_node and get_nodes"
6057            );
6058            assert_eq!(
6059                found.heart_rate,
6060                Some(72),
6061                "heart_rate dropped between put_node and get_nodes"
6062            );
6063            assert_eq!(found.cell_id.as_deref(), Some("BRAVO"));
6064        }
6065
6066        /// JNI inline-parser path: the publish surface consumers
6067        /// actually hit. Builds a JSON envelope shaped exactly like
6068        /// a typical self-position broadcaster would publish, runs
6069        /// it through the same `parse_node_publish_json` helper
6070        /// `publishNodeJni` invokes, and verifies battery + heart
6071        /// land in the resulting `NodeInfo`. Locks the duplicated
6072        /// codec — pre-2026-05-08 this was inlined inside the JNI
6073        /// function and unit tests couldn't reach it, which is how
6074        /// peat#835's bug class (silent field drop on the publish
6075        /// path) shipped without a CI signal.
6076        #[test]
6077        fn publish_json_inline_parser_extracts_battery_and_heart() {
6078            let json = r#"{
6079                "id": "ANDROID-abc123",
6080                "name": "HOBO",
6081                "node_type": "SOLDIER",
6082                "lat": 33.71576,
6083                "lon": -84.41152,
6084                "hae": 305.0,
6085                "status": "ACTIVE",
6086                "capabilities": ["PLI"],
6087                "readiness": 1.0,
6088                "cell_id": "BRAVO",
6089                "battery_percent": 85,
6090                "heart_rate": 72
6091            }"#;
6092
6093            let parsed = parse_node_publish_json(json).expect("parse");
6094
6095            assert_eq!(parsed.id, "ANDROID-abc123");
6096            assert_eq!(parsed.battery_percent, Some(85));
6097            assert_eq!(parsed.heart_rate, Some(72));
6098            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6099            assert!(parsed.capabilities.contains(&"PLI".to_string()));
6100        }
6101
6102        /// Reject an empty `id` at the publish boundary — the id is
6103        /// the storage key downstream. The pre-extraction inline code
6104        /// returned 0/JNI_FALSE on this case; the test pins the
6105        /// equivalent error contract.
6106        #[test]
6107        fn publish_json_rejects_missing_id() {
6108            let json = r#"{"name":"HOBO","node_type":"SOLDIER","lat":33.7,"lon":-84.4}"#;
6109            assert!(parse_node_publish_json(json).is_err());
6110
6111            let empty_id = r#"{"id":"","name":"HOBO","lat":33.7,"lon":-84.4}"#;
6112            assert!(parse_node_publish_json(empty_id).is_err());
6113        }
6114
6115        /// Out-of-range numeric values clamp to the logical end of
6116        /// the range rather than silently dropping to `None`. The
6117        /// silent-`None`-on-overflow shape is the same bug class
6118        /// peat#835 exists to lock — a pathological 2³² battery
6119        /// becoming "no sensor" is visually identical to the
6120        /// legitimate None case, which is exactly the data-loss
6121        /// failure mode the PR exists to prevent.
6122        #[test]
6123        fn battery_and_heart_clamp_out_of_range_numbers() {
6124            // Battery above 100 clamps to 100.
6125            let high = serde_json::json!(9999);
6126            assert_eq!(parse_battery_percent(&high), Some(100));
6127
6128            // Negative battery clamps to 0.
6129            let neg = serde_json::json!(-50);
6130            assert_eq!(parse_battery_percent(&neg), Some(0));
6131
6132            // i64::MAX clamps to 100 — the silent-None-on-overflow
6133            // case the pre-clamp `as_i64().and_then(i32::try_from)`
6134            // chain produced None for. After clamp, fail-safe.
6135            let huge = serde_json::json!(i64::MAX);
6136            assert_eq!(parse_battery_percent(&huge), Some(100));
6137
6138            // Heart rate above 250 clamps to 250 (max plausible BPM).
6139            let bpm_high = serde_json::json!(500);
6140            assert_eq!(parse_heart_rate(&bpm_high), Some(250));
6141
6142            // Heart rate below 0 clamps to 0; legitimate low BPM
6143            // (bradycardia, asystole) passes through unchanged. The
6144            // 30-floor was lowered in round-3 — see
6145            // `heart_rate_preserves_bradycardia_below_30`.
6146            let bpm_neg = serde_json::json!(-50);
6147            assert_eq!(parse_heart_rate(&bpm_neg), Some(0));
6148            let bpm_low_real = serde_json::json!(10);
6149            assert_eq!(parse_heart_rate(&bpm_low_real), Some(10));
6150        }
6151
6152        /// Non-numeric values (publisher serialization bug, hostile
6153        /// peer, schema drift) parse as `None` rather than coercing.
6154        /// We accept "no sensor" but reject silent type coercion —
6155        /// `"85"` as a JSON string is a publisher bug, not a value
6156        /// to interpret.
6157        #[test]
6158        fn battery_and_heart_reject_non_numeric() {
6159            let s = serde_json::json!("85");
6160            assert!(parse_battery_percent(&s).is_none());
6161            assert!(parse_heart_rate(&s).is_none());
6162
6163            let null = serde_json::Value::Null;
6164            assert!(parse_battery_percent(&null).is_none());
6165            assert!(parse_heart_rate(&null).is_none());
6166
6167            let arr = serde_json::json!([85]);
6168            assert!(parse_battery_percent(&arr).is_none());
6169        }
6170
6171        /// Forward-compat: a peer running a future schema that adds
6172        /// fields we don't know about must still parse cleanly,
6173        /// silently dropping the unknowns. Locks the existing
6174        /// `unwrap_or` / `optional`-style behavior so a future
6175        /// stricter parser doesn't regress this on accident.
6176        #[test]
6177        fn parse_silently_drops_unknown_future_fields() {
6178            let json = r#"{
6179                "node_type": "SOLDIER",
6180                "name": "FUTURE-PEER",
6181                "status": "ACTIVE",
6182                "lat": 33.71,
6183                "lon": -84.41,
6184                "readiness": 1.0,
6185                "capabilities": ["PLI"],
6186                "cell_id": "BRAVO",
6187                "battery_percent": 90,
6188                "last_heartbeat": 1700000000000,
6189
6190                "future_v2_field_one": "should be ignored",
6191                "future_v2_struct": { "nested": 42 },
6192                "future_v2_array": [1, 2, 3]
6193            }"#;
6194
6195            let parsed =
6196                parse_node_json("FUTURE-PEER", json).expect("future-shaped json must parse");
6197            assert_eq!(parsed.battery_percent, Some(90));
6198            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6199            // No assertion about the unknown fields — they're
6200            // intentionally dropped on the floor. The test exists to
6201            // keep us honest if anyone tries to switch to a stricter
6202            // `serde_json::from_str::<TypedStruct>` shape.
6203        }
6204
6205        /// **Round-3 / peat#835 review item P2-1**: float-typed
6206        /// numeric wire payloads must not silently drop. The
6207        /// pre-round-3 implementation used `as_i64()?` which returns
6208        /// `None` for any JSON Number stored as float — a Kotlin
6209        /// publisher serializing `battery_percent` as `Double`
6210        /// (`85.0`), or any node whose JSON serializer renders
6211        /// integers with a trailing `.0`, would silently lose the
6212        /// field. That's the same data-loss bug class peat#835 was
6213        /// opened to lock in the first place.
6214        #[test]
6215        fn battery_accepts_float_form() {
6216            assert_eq!(parse_battery_percent(&serde_json::json!(85.0)), Some(85));
6217            // Fractional rounds to nearest.
6218            assert_eq!(parse_battery_percent(&serde_json::json!(85.7)), Some(86));
6219            assert_eq!(parse_battery_percent(&serde_json::json!(85.4)), Some(85));
6220            // Float still clamps.
6221            assert_eq!(parse_battery_percent(&serde_json::json!(150.0)), Some(100));
6222            assert_eq!(parse_battery_percent(&serde_json::json!(-10.5)), Some(0));
6223        }
6224
6225        #[test]
6226        fn heart_rate_accepts_float_form() {
6227            assert_eq!(parse_heart_rate(&serde_json::json!(72.0)), Some(72));
6228            assert_eq!(parse_heart_rate(&serde_json::json!(72.6)), Some(73));
6229            assert_eq!(parse_heart_rate(&serde_json::json!(300.0)), Some(250));
6230        }
6231
6232        /// Bradycardia: athletic resting HR can dip into the 20s,
6233        /// asystole reads as 0. Round-3 lowered the floor from 30 to
6234        /// 0 so the UI gets the truth and can decide what to flag.
6235        /// The pre-round-3 floor of 30 silently rounded these up,
6236        /// hiding the very signal a heart-rate indicator should
6237        /// surface.
6238        #[test]
6239        fn heart_rate_preserves_bradycardia_below_30() {
6240            assert_eq!(parse_heart_rate(&serde_json::json!(25)), Some(25));
6241            assert_eq!(parse_heart_rate(&serde_json::json!(0)), Some(0));
6242            // Negative still clamps to 0 — sensor noise / signed-int
6243            // serialization bug.
6244            assert_eq!(parse_heart_rate(&serde_json::json!(-5)), Some(0));
6245        }
6246
6247        /// **Round-3**: extracted emit-side codec
6248        /// `serialize_nodes_get_json` mirrors the parse-side
6249        /// extraction (`parse_node_publish_json`). Without the
6250        /// extraction, the inline `getNodesJni` json! macro was a
6251        /// duplicated codec the test suite couldn't reach — same
6252        /// drift class peat#835 originally exposed on the parse side.
6253        /// This test pins the emit shape end-to-end.
6254        #[test]
6255        fn serialize_nodes_get_json_round_trips_through_parser() {
6256            let original = NodeInfo {
6257                id: "ANDROID-emit".to_string(),
6258                node_type: "SOLDIER".to_string(),
6259                name: "EMIT-TEST".to_string(),
6260                status: NodeStatus::Active,
6261                lat: 33.71576,
6262                lon: -84.41152,
6263                hae: Some(305.0),
6264                readiness: 1.0,
6265                capabilities: vec!["PLI".to_string()],
6266                cell_id: Some("BRAVO".to_string()),
6267                battery_percent: Some(85),
6268                heart_rate: Some(72),
6269                last_heartbeat: 1_700_000_000_000,
6270            };
6271
6272            let emitted = serialize_nodes_get_json(std::slice::from_ref(&original));
6273            let arr: Vec<serde_json::Value> = serde_json::from_str(&emitted).expect("array");
6274            assert_eq!(arr.len(), 1);
6275
6276            // Parse the emitted JSON back through the storage parser
6277            // (the path `getNodes` consumers' downstream Kotlin
6278            // parsers mirror) and assert symmetry.
6279            let obj_str = serde_json::to_string(&arr[0]).expect("obj");
6280            let parsed = parse_node_json(&original.id, &obj_str).expect("parse");
6281            assert_eq!(parsed.battery_percent, Some(85));
6282            assert_eq!(parsed.heart_rate, Some(72));
6283            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
6284            assert_eq!(parsed.last_heartbeat, 1_700_000_000_000);
6285        }
6286
6287        /// **Round-3 P3-1**: when a publisher provides a
6288        /// `last_heartbeat` on the wire, the publish-path parser
6289        /// honors it instead of stamping `Utc::now()`. Resolves the
6290        /// doc-comment-vs-behavior tension: the field doc-comment
6291        /// describes a "0 means stale" convention that the publish
6292        /// path was actively preventing from ever shipping.
6293        #[test]
6294        fn publish_json_honors_wire_last_heartbeat() {
6295            let supplied: i64 = 1_700_000_123_456;
6296            let json = format!(
6297                r#"{{
6298                    "id": "ANDROID-replay",
6299                    "name": "REPLAY",
6300                    "node_type": "SOLDIER",
6301                    "lat": 0.0, "lon": 0.0,
6302                    "status": "ACTIVE",
6303                    "last_heartbeat": {}
6304                }}"#,
6305                supplied
6306            );
6307            let parsed = parse_node_publish_json(&json).expect("parse");
6308            assert_eq!(parsed.last_heartbeat, supplied);
6309        }
6310
6311        /// And: when the wire omits `last_heartbeat`, fall back to
6312        /// `now()` (preserving back-compat with publishers that don't
6313        /// stamp the field).
6314        #[test]
6315        fn publish_json_stamps_now_when_last_heartbeat_absent() {
6316            let before = chrono::Utc::now().timestamp_millis();
6317            let json = r#"{
6318                "id": "ANDROID-no-stamp",
6319                "name": "FRESH",
6320                "node_type": "SOLDIER",
6321                "lat": 0.0, "lon": 0.0,
6322                "status": "ACTIVE"
6323            }"#;
6324            let parsed = parse_node_publish_json(json).expect("parse");
6325            let after = chrono::Utc::now().timestamp_millis();
6326            assert!(
6327                parsed.last_heartbeat >= before && parsed.last_heartbeat <= after,
6328                "last_heartbeat ({}) should be in [{}, {}]",
6329                parsed.last_heartbeat,
6330                before,
6331                after
6332            );
6333        }
6334
6335        /// **Round-4 P1**: wire `last_heartbeat: 0` is the documented
6336        /// stale-record sentinel per the `NodeInfo` field doc;
6337        /// must round-trip unchanged. Round-3's `> 0` filter
6338        /// inverted this contract, silently replacing the
6339        /// stale-marker with `Utc::now()`. Test pins the corrected
6340        /// behavior so the regression can't recur.
6341        #[test]
6342        fn publish_json_preserves_wire_last_heartbeat_zero_as_stale_marker() {
6343            let json = r#"{
6344                "id": "ANDROID-stale",
6345                "name": "STALE",
6346                "node_type": "SOLDIER",
6347                "lat": 0.0, "lon": 0.0,
6348                "status": "ACTIVE",
6349                "last_heartbeat": 0
6350            }"#;
6351            let parsed = parse_node_publish_json(json).expect("parse");
6352            assert_eq!(
6353                parsed.last_heartbeat, 0,
6354                "wire `last_heartbeat: 0` must pass through as the stale-record sentinel"
6355            );
6356        }
6357
6358        /// **Round-4 P1 / P2**: smallest non-zero positive timestamp
6359        /// (`1`) and a small value (`12345`) both pass through as-is.
6360        /// These are the boundary values around the prior `> 0`
6361        /// filter; round-4 dropped the filter, so all positive values
6362        /// short of the future-skew clamp must round-trip.
6363        #[test]
6364        fn publish_json_preserves_small_positive_last_heartbeat() {
6365            for wire in [1_i64, 12_345, 1_700_000_000_000] {
6366                let json = format!(
6367                    r#"{{"id":"ANDROID-{w}","name":"X","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{w}}}"#,
6368                    w = wire,
6369                );
6370                let parsed = parse_node_publish_json(&json).expect("parse");
6371                assert_eq!(
6372                    parsed.last_heartbeat, wire,
6373                    "wire `{}` must round-trip",
6374                    wire
6375                );
6376            }
6377        }
6378
6379        /// **Round-4 P2 #4**: clock-skew injection guard. A peer with
6380        /// a far-future-skewed clock can publish `i64::MAX` (or any
6381        /// timestamp beyond `now() + 60s` grace); the parser caps to
6382        /// `now()` so downstream staleness UI can't be gamed into
6383        /// "always fresh." Negative values pass through (very stale,
6384        /// but not absurd).
6385        #[test]
6386        fn publish_json_clamps_far_future_last_heartbeat_to_now() {
6387            let json = r#"{
6388                "id": "ANDROID-malicious",
6389                "name": "MALICIOUS",
6390                "node_type": "SOLDIER",
6391                "lat": 0.0, "lon": 0.0,
6392                "status": "ACTIVE",
6393                "last_heartbeat": 9223372036854775807
6394            }"#;
6395            let before = chrono::Utc::now().timestamp_millis();
6396            let parsed = parse_node_publish_json(json).expect("parse");
6397            let after = chrono::Utc::now().timestamp_millis();
6398            assert!(
6399                parsed.last_heartbeat >= before && parsed.last_heartbeat <= after,
6400                "i64::MAX must clamp to now(), got {}",
6401                parsed.last_heartbeat
6402            );
6403        }
6404
6405        /// **Round-5**: negative `last_heartbeat` collapses to the
6406        /// stale-marker (`0`) rather than passing through. Round-4
6407        /// let negatives through with a doc-comment claim that
6408        /// downstream Long arithmetic produced a "sensible large
6409        /// positive age" — that was wrong: `now - i64::MIN`
6410        /// overflows, and the Kotlin `Long` subtraction silently
6411        /// wraps. Pin the corrected behavior so a malicious peer
6412        /// publishing `last_heartbeat: i64::MIN` can't game the
6413        /// staleness UI in the opposite direction from the
6414        /// `i64::MAX` case.
6415        #[test]
6416        fn publish_json_clamps_negative_last_heartbeat_to_zero() {
6417            for wire in [-1_i64, -1_700_000_000_000, i64::MIN] {
6418                let json = format!(
6419                    r#"{{"id":"ANDROID-neg-{w}","name":"NEG","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{w}}}"#,
6420                    w = wire,
6421                );
6422                let parsed = parse_node_publish_json(&json)
6423                    .unwrap_or_else(|e| panic!("wire {} must parse: {:?}", wire, e));
6424                assert_eq!(
6425                    parsed.last_heartbeat, 0,
6426                    "negative wire `{}` must collapse to stale-marker `0`",
6427                    wire
6428                );
6429            }
6430        }
6431
6432        /// Wire timestamp within the 60-second future-grace window
6433        /// passes through (legitimate clock drift between mobile
6434        /// devices on unrelated networks). Beyond grace, clamp.
6435        #[test]
6436        fn publish_json_within_grace_window_passes_through_then_clamps_beyond() {
6437            let now = chrono::Utc::now().timestamp_millis();
6438            // 30 s in the future — within grace.
6439            let in_grace = now + 30_000;
6440            let json = format!(
6441                r#"{{"id":"ANDROID-grace","name":"G","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{}}}"#,
6442                in_grace
6443            );
6444            let parsed = parse_node_publish_json(&json).expect("parse");
6445            assert_eq!(parsed.last_heartbeat, in_grace);
6446
6447            // 5 minutes in the future — beyond 60 s grace, clamp.
6448            let beyond = chrono::Utc::now().timestamp_millis() + 5 * 60 * 1000;
6449            let json2 = format!(
6450                r#"{{"id":"ANDROID-skew","name":"S","node_type":"SOLDIER","lat":0.0,"lon":0.0,"status":"ACTIVE","last_heartbeat":{}}}"#,
6451                beyond
6452            );
6453            let parsed2 = parse_node_publish_json(&json2).expect("parse");
6454            assert!(
6455                parsed2.last_heartbeat < beyond,
6456                "5min-future must clamp ({} should be << {})",
6457                parsed2.last_heartbeat,
6458                beyond
6459            );
6460        }
6461
6462        /// **Round-4 P3 #7**: float rounding mode is half-away-from-zero
6463        /// per `f64::round()`. Pin the contract so a future refactor to
6464        /// `round_ties_even` (banker's) doesn't silently change the
6465        /// emitted i32 by ±1 for half-values.
6466        #[test]
6467        fn battery_percent_rounds_halves_away_from_zero() {
6468            assert_eq!(parse_battery_percent(&serde_json::json!(85.5)), Some(86));
6469            assert_eq!(parse_battery_percent(&serde_json::json!(84.5)), Some(85));
6470            // 0.5 rounds to 1, not 0 (half-away-from-zero, not
6471            // banker's-rounding).
6472            assert_eq!(parse_battery_percent(&serde_json::json!(0.5)), Some(1));
6473        }
6474
6475        /// **Round-4 P3 #9**: forward-compat for the publish parser.
6476        /// Mirror of `parse_silently_drops_unknown_future_fields`
6477        /// for the storage parser; both share the
6478        /// `serde_json::Value`-indexing pattern but the contract
6479        /// should be locked separately so a future refactor of
6480        /// either to a typed `serde::Deserialize` doesn't regress
6481        /// half the surface unnoticed.
6482        #[test]
6483        fn publish_json_silently_drops_unknown_future_fields() {
6484            let json = r#"{
6485                "id": "ANDROID-future",
6486                "name": "FUTURE",
6487                "node_type": "SOLDIER",
6488                "lat": 33.71, "lon": -84.41,
6489                "status": "ACTIVE",
6490                "battery_percent": 90,
6491
6492                "future_v2_field_one": "should be ignored",
6493                "future_v2_struct": { "nested": 42 },
6494                "future_v2_array": [1, 2, 3]
6495            }"#;
6496            let parsed = parse_node_publish_json(json).expect("future-shaped publish must parse");
6497            assert_eq!(parsed.battery_percent, Some(90));
6498            assert_eq!(parsed.id, "ANDROID-future");
6499        }
6500    }
6501
6502    /// End-to-end round-trip tests for the track storage path that
6503    /// `Java_..._ingestPositionJni` and `Java_..._getTracksJni` expose
6504    /// to consumer plugins.
6505    ///
6506    /// peat#832 (open as of 2026-05-08) reports the BLE-bridged tracks
6507    /// surface every body field at `parse_track_json`'s `unwrap_or`
6508    /// default (lat/lon=0.0, classification="a-u-G", confidence=0.5,
6509    /// source_node="unknown") even though `ingest_position_via_translator`
6510    /// publishes valid coordinates. The hypothesis the issue records:
6511    /// the writer publishes via `peat_mesh::Node::publish_with_origin`
6512    /// (Document API → Automerge map storage), but the reader uses
6513    /// `AutomergeBackend::collection().scan()` which returns bytes the
6514    /// reader assumes are flat JSON. The two APIs disagree on the
6515    /// on-disk shape, so body fields don't survive the round-trip.
6516    ///
6517    /// Existing `ingest_position_tests` (line ~2520) wires
6518    /// `peat_mesh::Node` against an `InMemoryBackend` from peat-mesh —
6519    /// that backend doesn't carry the AutomergeBackend / Collection
6520    /// scan asymmetry, so it has no way to reproduce the bug. The
6521    /// tests below use `create_node()` (the same factory the JNI
6522    /// surface uses) so the AutomergeBackend disagreement is in scope.
6523    ///
6524    /// `ingest_position_via_translator_then_get_tracks_preserves_body`
6525    /// is the regression gate: pre-fix it failed deterministically,
6526    /// post-fix it locks the symmetry. The dev-team-owns-validation
6527    /// memory captures the broader pattern.
6528    #[cfg(all(feature = "sync", feature = "bluetooth"))]
6529    mod track_tests {
6530        use super::*;
6531        use peat_protocol::sync::ble_translation::{
6532            value_to_mesh_document, BlePosition, BleTranslator,
6533        };
6534
6535        /// Test fixture that holds both the constructed node and the
6536        /// tempdir backing its storage. Bind both via `let _node_fx =
6537        /// ingest_position_test_node();` and let the drop order do the
6538        /// right thing — `Drop for PeatNode` (and its inner
6539        /// `AutomergeStore`) runs first, then the tempdir's
6540        /// `Drop for TempDir` removes the on-disk directory.
6541        ///
6542        /// Earlier this fixture used `std::mem::forget(tmp)` on the
6543        /// `TempDir` with a comment claiming "Tempdirs are nuked at
6544        /// process exit anyway" — that's wrong: `tempfile::TempDir`
6545        /// cleanup runs in its `Drop` impl, which `mem::forget` skips,
6546        /// and process exit doesn't trigger OS-level `/tmp` cleanup.
6547        /// Re-running `cargo test track_tests` locally accumulated
6548        /// `/tmp/.tmpXXXXXX` directories until reboot.
6549        struct TrackFixture {
6550            node: Arc<PeatNode>,
6551            // Field is read via the binding lifetime (Drop runs after
6552            // `node`), not by the test body. `dead_code` would lint
6553            // otherwise — `_tmp` makes the role explicit.
6554            #[allow(dead_code)]
6555            _tmp: tempfile::TempDir,
6556        }
6557
6558        fn ingest_position_test_node() -> TrackFixture {
6559            let tmp = tempfile::tempdir().expect("tempdir");
6560            let path = tmp.path().to_str().expect("tempdir path utf-8").to_string();
6561
6562            let node = create_node(NodeConfig {
6563                app_id: "track-rt-test".to_string(),
6564                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
6565                bind_address: Some("127.0.0.1:0".to_string()),
6566                storage_path: path,
6567                transport: None,
6568            })
6569            .expect("create_node");
6570
6571            TrackFixture { node, _tmp: tmp }
6572        }
6573
6574        /// Sanity check the **flat-JSON** path: `put_track` →
6575        /// `serialize_track_json` → `coll.upsert(json_bytes)` → `coll.scan()`
6576        /// → `parse_track_json` → `get_tracks`. Both writer and reader
6577        /// use the same flat-JSON shape, so this should round-trip
6578        /// today. If this ever fails, the asymmetry has spread to
6579        /// even the typed-API path.
6580        #[test]
6581        fn put_track_get_tracks_preserves_body() {
6582            let fx = ingest_position_test_node();
6583            let pn = &fx.node;
6584
6585            let original = TrackInfo {
6586                id: "manual-001".to_string(),
6587                source_node: "ANDROID-tablet".to_string(),
6588                cell_id: Some("BRAVO".to_string()),
6589                formation_id: None,
6590                lat: 33.71576,
6591                lon: -84.41152,
6592                hae: Some(305.0),
6593                cep: Some(5.0),
6594                heading: Some(87.5),
6595                speed: Some(1.2),
6596                classification: "a-f-G-U-C-I".to_string(),
6597                confidence: 0.9,
6598                category: TrackCategory::Person,
6599                created_at: 1_700_000_000_000,
6600                last_update: 1_700_000_000_000,
6601                attributes: std::collections::HashMap::new(),
6602            };
6603
6604            pn.put_track(original.clone()).expect("put_track");
6605            let listed = pn.get_tracks().expect("get_tracks");
6606            let found = listed
6607                .iter()
6608                .find(|t| t.id == "manual-001")
6609                .expect("track must appear");
6610
6611            assert!(
6612                (found.lat - original.lat).abs() < 1e-9,
6613                "lat dropped via put_track/get_tracks: got {}",
6614                found.lat
6615            );
6616            assert!(
6617                (found.lon - original.lon).abs() < 1e-9,
6618                "lon dropped via put_track/get_tracks: got {}",
6619                found.lon
6620            );
6621            assert_eq!(found.cell_id.as_deref(), Some("BRAVO"));
6622            assert_eq!(found.source_node, original.source_node);
6623            assert_eq!(found.classification, original.classification);
6624        }
6625
6626        /// peat#832 regression gate: the **BLE-bridged path** that
6627        /// `ingestPositionJni` exercises on every BLE peer's position
6628        /// advert. Writer goes through `Node::publish_with_origin`
6629        /// (Document API); the original reader went through
6630        /// `AutomergeBackend::collection().scan()` (flat-JSON API),
6631        /// and the two storage-API namespaces disagreed — every body
6632        /// field came back as a `parse_track_json` `unwrap_or`
6633        /// default (lat/lon=0.0, source_node="unknown",
6634        /// classification="a-u-G"). Fix routes `get_tracks` through
6635        /// `Node::query` so writer and reader share the Document API,
6636        /// and `put_track` was migrated to `Node::publish` to keep
6637        /// the typed-API path consistent. If either path breaks, this
6638        /// test catches it before on-device UAT does.
6639        #[test]
6640        fn ingest_position_via_translator_then_get_tracks_preserves_body() {
6641            let fx = ingest_position_test_node();
6642            let pn = &fx.node;
6643            let translator = BleTranslator::with_defaults();
6644
6645            const PERIPHERAL: u32 = 0xCAFE_0001;
6646            let position = BlePosition {
6647                latitude: 33.71576,
6648                longitude: -84.41152,
6649                altitude: Some(305.0),
6650                accuracy: Some(5.0),
6651            };
6652            let value = translator.position_to_track_in_cell(
6653                &position,
6654                PERIPHERAL,
6655                Some("SCOUT-CAFE"),
6656                Some("BRAVO"),
6657            );
6658            let doc = value_to_mesh_document(value);
6659
6660            pn.runtime.block_on(async {
6661                pn.node
6662                    .publish_with_origin(
6663                        translator.tracks_collection(),
6664                        doc,
6665                        Some("ble".to_string()),
6666                    )
6667                    .await
6668                    .expect("publish_with_origin");
6669            });
6670
6671            let tracks = pn.get_tracks().expect("get_tracks");
6672            let found = tracks
6673                .iter()
6674                .find(|t| t.id.contains("CAFE0001"))
6675                .expect("BLE-bridged track must appear in get_tracks output");
6676
6677            assert!(
6678                (found.lat - 33.71576).abs() < 1e-4,
6679                "peat#832: lat dropped — got {} (expected ~33.71576)",
6680                found.lat
6681            );
6682            assert!(
6683                (found.lon - (-84.41152)).abs() < 1e-4,
6684                "peat#832: lon dropped — got {} (expected ~-84.41152)",
6685                found.lon
6686            );
6687            assert_eq!(
6688                found.cell_id.as_deref(),
6689                Some("BRAVO"),
6690                "peat#832: cell_id dropped"
6691            );
6692            assert!(
6693                !found.source_node.is_empty() && found.source_node != "unknown",
6694                "peat#832: source_node reverted to default — got {:?}",
6695                found.source_node
6696            );
6697            assert_ne!(
6698                found.classification, "a-u-G",
6699                "peat#832: classification reverted to default a-u-G"
6700            );
6701        }
6702
6703        /// Single-id read path: `get_track(id)` migrated to
6704        /// `Node::get` along with `get_tracks` (PR #836). Without
6705        /// this test the per-id path was silent in the regression
6706        /// suite — same bug class could re-emerge on it without a
6707        /// signal.
6708        #[test]
6709        fn ingest_position_then_get_track_single_id_preserves_body() {
6710            let fx = ingest_position_test_node();
6711            let pn = &fx.node;
6712            let translator = BleTranslator::with_defaults();
6713
6714            const PERIPHERAL: u32 = 0xCAFE_0002;
6715            let position = BlePosition {
6716                latitude: 33.71576,
6717                longitude: -84.41152,
6718                altitude: Some(305.0),
6719                accuracy: Some(5.0),
6720            };
6721            let value = translator.position_to_track_in_cell(
6722                &position,
6723                PERIPHERAL,
6724                Some("SCOUT-ID-2"),
6725                Some("BRAVO"),
6726            );
6727            let track_id = value
6728                .get("id")
6729                .and_then(|v| v.as_str())
6730                .expect("translator stamps id")
6731                .to_string();
6732            let doc = value_to_mesh_document(value);
6733
6734            pn.runtime.block_on(async {
6735                pn.node
6736                    .publish_with_origin(
6737                        translator.tracks_collection(),
6738                        doc,
6739                        Some("ble".to_string()),
6740                    )
6741                    .await
6742                    .expect("publish_with_origin");
6743            });
6744
6745            let single = pn
6746                .get_track(&track_id)
6747                .expect("get_track")
6748                .expect("track must exist for known id");
6749
6750            assert!((single.lat - 33.71576).abs() < 1e-4);
6751            assert!((single.lon - (-84.41152)).abs() < 1e-4);
6752            assert_eq!(single.cell_id.as_deref(), Some("BRAVO"));
6753            assert_eq!(single.id, track_id);
6754        }
6755
6756        /// Pre-fix-shape entries (written via `coll.upsert(json_bytes)`
6757        /// before this PR) won't decode through `Node::query`'s
6758        /// `serde_json::from_slice::<Document>` reader and are silently
6759        /// dropped. Codifies the migration story: devices upgrading to
6760        /// a new `libpeat_ffi.so` will *not* see pre-fix tracks until
6761        /// the BLE peer republishes (every ~5 s in normal operation),
6762        /// but they also won't crash on the stale bytes.
6763        ///
6764        /// Test writes a fake old-shape entry directly through the
6765        /// untyped Collection surface, then calls `get_tracks` and
6766        /// asserts (a) it doesn't error, (b) the legacy entry is
6767        /// invisible. `put_track` itself can't be used here because
6768        /// PR #836 migrated it to `Node::publish` (correctly), so
6769        /// reaching the old shape requires going through
6770        /// `storage_backend.collection().upsert(...)` directly.
6771        #[test]
6772        fn pre_fix_flat_json_entries_are_silently_dropped_not_crashed() {
6773            let fx = ingest_position_test_node();
6774            let pn = &fx.node;
6775
6776            // Old-shape: flat JSON of the body, written via the
6777            // untyped Collection upsert (the pre-#836 `put_track`
6778            // codepath). Bytes are intentionally well-formed JSON so
6779            // any *parse* error that fires would be in the Document
6780            // deserialization step, not in JSON tokenization.
6781            let legacy = serde_json::json!({
6782                "source_node": "ble-DEAD0001",
6783                "lat": 33.0,
6784                "lon": -84.0,
6785                "classification": "a-f-G-U-C-I",
6786                "confidence": 0.9,
6787                "category": "PERSON",
6788                "created_at": 1_700_000_000_000_i64,
6789                "last_update": 1_700_000_000_000_i64,
6790            })
6791            .to_string()
6792            .into_bytes();
6793
6794            // `pn.storage_backend` is `Arc<AutomergeBackend>` from
6795            // `peat_protocol::storage`; its `StorageBackend::collection`
6796            // returns the untyped `Arc<dyn Collection>` whose
6797            // `upsert(doc_id, Vec<u8>)` is the pre-#836 write path the
6798            // bug originally lived in.
6799            let coll = pn.storage_backend.collection(collections::TRACKS);
6800            coll.upsert("legacy-track-DEAD0001", legacy)
6801                .expect("legacy upsert must succeed");
6802
6803            // get_tracks must not error.
6804            let listed = pn.get_tracks().expect("get_tracks must not panic");
6805
6806            // The legacy entry must NOT appear via the Node::query
6807            // path — its bytes don't decode as a Document, so it's
6808            // silently dropped per the documented migration semantics.
6809            assert!(
6810                listed.iter().all(|t| t.id != "legacy-track-DEAD0001"),
6811                "pre-fix legacy entry must be silently invisible after migration: {:?}",
6812                listed.iter().map(|t| &t.id).collect::<Vec<_>>()
6813            );
6814        }
6815    }
6816
6817    /// Marker tombstone schema. peat-mesh's fan-out skips
6818    /// `ChangeEvent::Removed` today (Slice-2 work), so deletion of
6819    /// a synced marker is communicated via a `_deleted: true`
6820    /// sentinel ridden on the Updated channel. Consumers publish a
6821    /// tombstone on deletion and filter `_deleted: true` entries out
6822    /// of "current markers" views on render. These tests pin the
6823    /// wire shape so a future schema change has to pass through the
6824    /// test gate first.
6825    mod marker_tombstone {
6826        use super::*;
6827
6828        /// A minimum-viable tombstone publish carries `uid` +
6829        /// `_deleted: true` only — the publisher omits type/lat/lon
6830        /// to keep the BLE frame small. The parser must accept this
6831        /// shape (placeholders for the absent geo fields), set
6832        /// `deleted = true`, and round-trip cleanly.
6833        #[test]
6834        fn parse_minimal_tombstone() {
6835            let json = r#"{"uid":"abc-123","_deleted":true,"ts":1700000000000}"#;
6836            let m = parse_marker_publish_json("", json).expect("minimal tombstone parses");
6837            assert!(m.deleted, "deleted flag set");
6838            assert_eq!(m.uid, "abc-123");
6839            assert_eq!(m.ts, 1700000000000);
6840        }
6841
6842        /// A live (non-tombstone) marker still requires type/lat/lon.
6843        /// Drops `_deleted` from the body — the parser must default
6844        /// `deleted = false` and enforce the required-fields contract
6845        /// it enforced before the tombstone shape was added.
6846        #[test]
6847        fn parse_live_marker_requires_geo() {
6848            let no_type = r#"{"uid":"x","lat":1.0,"lon":2.0}"#;
6849            assert!(parse_marker_publish_json("", no_type).is_err());
6850
6851            let no_lat = r#"{"uid":"x","type":"a-f-G","lon":2.0}"#;
6852            assert!(parse_marker_publish_json("", no_lat).is_err());
6853
6854            let no_lon = r#"{"uid":"x","type":"a-f-G","lat":1.0}"#;
6855            assert!(parse_marker_publish_json("", no_lon).is_err());
6856
6857            let ok = r#"{"uid":"x","type":"a-f-G","lat":1.0,"lon":2.0}"#;
6858            let m = parse_marker_publish_json("", ok).expect("live marker parses");
6859            assert!(!m.deleted);
6860        }
6861
6862        /// `serialize_marker_json` round-trips a tombstone. The
6863        /// `_deleted: true` key MUST appear in the output (otherwise
6864        /// peers receiving the doc see a normal-looking marker and
6865        /// re-render it after a refresh tick — the deletion would
6866        /// "un-do" itself).
6867        #[test]
6868        fn serialize_tombstone_includes_deleted_key() {
6869            let m = MarkerInfo {
6870                uid: "abc-123".to_string(),
6871                marker_type: "a-u-G".to_string(),
6872                lat: 0.0,
6873                lon: 0.0,
6874                hae: None,
6875                ts: 1700000000000,
6876                callsign: None,
6877                color: None,
6878                cell_id: None,
6879                deleted: true,
6880            };
6881            let json = serialize_marker_json(&m).expect("serializes");
6882            assert!(
6883                json.contains("\"_deleted\":true"),
6884                "tombstone serialization must include _deleted key, got: {json}"
6885            );
6886        }
6887
6888        /// A live marker's serialization MUST NOT include `_deleted`
6889        /// (saves bytes on the wire AND avoids ambiguity for
6890        /// receivers running an older parser that does a strict
6891        /// `_deleted == true` check).
6892        #[test]
6893        fn serialize_live_marker_omits_deleted_key() {
6894            let m = MarkerInfo {
6895                uid: "abc-123".to_string(),
6896                marker_type: "a-f-G-U-C".to_string(),
6897                lat: 33.71,
6898                lon: -84.41,
6899                hae: Some(312.4),
6900                ts: 1700000000000,
6901                callsign: Some("ALPHA-1".to_string()),
6902                color: Some(-65536),
6903                cell_id: None,
6904                deleted: false,
6905            };
6906            let json = serialize_marker_json(&m).expect("serializes");
6907            assert!(
6908                !json.contains("_deleted"),
6909                "live marker must not emit _deleted key, got: {json}"
6910            );
6911        }
6912
6913        /// `serialize_markers_get_json` (the get_markers / scan-side
6914        /// shape, an array) preserves the tombstone flag when the
6915        /// doc store contains both live and deleted entries. The
6916        /// plugin's `renderAllMarkersFromDocStore` reads this output
6917        /// and must be able to identify which entries are tombstones.
6918        #[test]
6919        fn scan_serializes_tombstones_in_array() {
6920            let live = MarkerInfo {
6921                uid: "live".to_string(),
6922                marker_type: "a-f-G".to_string(),
6923                lat: 1.0,
6924                lon: 2.0,
6925                hae: None,
6926                ts: 1,
6927                callsign: None,
6928                color: None,
6929                cell_id: None,
6930                deleted: false,
6931            };
6932            let dead = MarkerInfo {
6933                deleted: true,
6934                ..live.clone()
6935            };
6936            let mut dead = dead;
6937            dead.uid = "dead".to_string();
6938
6939            let json = serialize_markers_get_json(&[live, dead]);
6940            let arr: serde_json::Value = serde_json::from_str(&json).unwrap();
6941            let arr = arr.as_array().unwrap();
6942            assert_eq!(arr.len(), 2);
6943            // Find by uid; can't rely on order.
6944            let live_obj = arr.iter().find(|v| v["uid"] == "live").unwrap();
6945            let dead_obj = arr.iter().find(|v| v["uid"] == "dead").unwrap();
6946            assert!(
6947                live_obj.get("_deleted").is_none(),
6948                "live entry has no _deleted"
6949            );
6950            assert_eq!(
6951                dead_obj["_deleted"].as_bool(),
6952                Some(true),
6953                "dead entry has _deleted: true"
6954            );
6955        }
6956
6957        /// Round-trip: serialize → parse → serialize. The two
6958        /// serialized strings must be byte-identical. Catches
6959        /// codec drift (e.g., one side adds a field the other
6960        /// drops, or `Option<i64> 0` vs absent disagreements).
6961        #[test]
6962        fn tombstone_round_trip_is_stable() {
6963            let m = MarkerInfo {
6964                uid: "round-trip-uid".to_string(),
6965                marker_type: "a-u-G".to_string(),
6966                lat: 0.0,
6967                lon: 0.0,
6968                hae: None,
6969                ts: 1700000000000,
6970                callsign: None,
6971                color: None,
6972                cell_id: None,
6973                deleted: true,
6974            };
6975            let s1 = serialize_marker_json(&m).unwrap();
6976            let parsed = parse_marker_publish_json("", &s1).expect("parses tombstone");
6977            assert!(parsed.deleted, "deleted flag preserved through round-trip");
6978            assert_eq!(parsed.uid, m.uid);
6979            let s2 = serialize_marker_json(&parsed).unwrap();
6980            assert_eq!(s1, s2, "round-trip must produce byte-identical output");
6981        }
6982    }
6983
6984    /// Surface-tier round-trips for the marker API the plugin
6985    /// actually consumes: the UniFFI `PeatNode::put_marker` /
6986    /// `PeatNode::get_markers` path (typed-record wrapper, doc-store
6987    /// persistence, `MARKERS` collection wiring) and the JNI
6988    /// `publishMarkerJni` / `getMarkersJni` path (inline parser +
6989    /// `serialize_markers_get_json`). These tests are the bidirectional
6990    /// E2E coverage the QA review on PR #845 required — internal
6991    /// codec tests in [`marker_tombstone`] don't catch wrapper-vs-
6992    /// internal drift (renamed UniFFI field, doc-store key mismatch,
6993    /// JNI handle lifecycle regression). Storage-side tests follow
6994    /// the `put_node_get_nodes_preserves_battery_and_heart`
6995    /// pattern in [`node_tests`]: `create_node` against
6996    /// `AutomergeBackend` (not `InMemoryBackend`, which silently
6997    /// papers over the publish-vs-scan storage-API asymmetry — see
6998    /// the InMemoryBackend test gap memory).
6999    #[cfg(feature = "sync")]
7000    mod marker_tests {
7001        use super::*;
7002
7003        fn live_marker(uid: &str) -> MarkerInfo {
7004            MarkerInfo {
7005                uid: uid.to_string(),
7006                marker_type: "a-f-G-U-C".to_string(),
7007                lat: 33.71576,
7008                lon: -84.41152,
7009                hae: Some(312.4),
7010                ts: 1_700_000_000_000,
7011                callsign: Some("ALPHA-1".to_string()),
7012                color: Some(-65536),
7013                cell_id: Some("BRAVO".to_string()),
7014                deleted: false,
7015            }
7016        }
7017
7018        fn tombstone_marker(uid: &str) -> MarkerInfo {
7019            MarkerInfo {
7020                uid: uid.to_string(),
7021                marker_type: TOMBSTONE_PLACEHOLDER_TYPE.to_string(),
7022                lat: 0.0,
7023                lon: 0.0,
7024                hae: None,
7025                ts: 1_700_000_000_000,
7026                callsign: None,
7027                color: None,
7028                cell_id: None,
7029                deleted: true,
7030            }
7031        }
7032
7033        fn make_node(label: &str) -> Arc<PeatNode> {
7034            let tmp = tempfile::tempdir().expect("tempdir");
7035            create_node(NodeConfig {
7036                app_id: format!("marker-rt-{label}"),
7037                shared_key: "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0".to_string(),
7038                bind_address: Some("127.0.0.1:0".to_string()),
7039                storage_path: tmp.path().to_str().unwrap().to_string(),
7040                transport: None,
7041            })
7042            .expect("create_node")
7043        }
7044
7045        // ----- UniFFI tier -------------------------------------------------
7046
7047        /// Live marker survives the full UniFFI surface round-trip.
7048        /// Drift point this catches: a future field added to
7049        /// `MarkerInfo` but dropped in `serialize_marker_json` or
7050        /// `parse_marker_publish_json` (the very bug pattern
7051        /// peat#835 / peat#832 sat behind). Every optional field
7052        /// must round-trip; new fields require a parallel assertion
7053        /// below so this matrix stays exhaustive.
7054        #[test]
7055        fn put_marker_get_markers_preserves_live_fields() {
7056            let node = make_node("live");
7057            let original = live_marker("marker-live-001");
7058            node.put_marker(original.clone()).expect("put_marker");
7059
7060            let listed = node.get_markers().expect("get_markers");
7061            let found = listed
7062                .iter()
7063                .find(|m| m.uid == original.uid)
7064                .expect("published marker must appear in get_markers");
7065
7066            assert_eq!(found.marker_type, original.marker_type);
7067            assert_eq!(found.lat, original.lat);
7068            assert_eq!(found.lon, original.lon);
7069            assert_eq!(found.hae, original.hae);
7070            assert_eq!(found.ts, original.ts);
7071            assert_eq!(found.callsign, original.callsign);
7072            assert_eq!(found.color, original.color);
7073            assert_eq!(found.cell_id, original.cell_id);
7074            assert!(!found.deleted, "live marker must not arrive deleted");
7075        }
7076
7077        /// Tombstone survives the UniFFI surface round-trip with the
7078        /// `deleted` flag preserved. Without this assertion a future
7079        /// schema refactor could silently drop `_deleted: true` on
7080        /// store-and-scan — receivers would render the marker as
7081        /// live, the deletion would never propagate, and the only
7082        /// signal would be on-device UAT (the exact bug class the
7083        /// dev-team-owns-validation rule exists to lock in CI).
7084        #[test]
7085        fn put_marker_get_markers_preserves_tombstone() {
7086            let node = make_node("tomb");
7087            let original = tombstone_marker("marker-tomb-001");
7088            node.put_marker(original.clone()).expect("put_marker");
7089
7090            let listed = node.get_markers().expect("get_markers");
7091            let found = listed
7092                .iter()
7093                .find(|m| m.uid == original.uid)
7094                .expect("published tombstone must appear in get_markers");
7095
7096            assert!(found.deleted, "tombstone must round-trip with deleted=true");
7097            assert_eq!(found.uid, original.uid);
7098            assert_eq!(found.ts, original.ts);
7099        }
7100
7101        /// Tombstone overwriting a live marker for the same UID:
7102        /// `put_marker` is upsert, the second write replaces the
7103        /// first. `get_markers` returns the tombstone (deleted=true),
7104        /// not the prior live shape. Locks the CRDT semantics the
7105        /// consumer's deletion flow depends on — without upsert,
7106        /// "delete a marker I just placed" would produce two
7107        /// doc-store entries and ambiguous resolution.
7108        #[test]
7109        fn tombstone_upserts_over_live_marker() {
7110            let node = make_node("upsert");
7111            let uid = "marker-upsert-001";
7112            node.put_marker(live_marker(uid)).expect("put live");
7113            node.put_marker(tombstone_marker(uid)).expect("put tomb");
7114
7115            let listed = node.get_markers().expect("get_markers");
7116            let matching: Vec<_> = listed.iter().filter(|m| m.uid == uid).collect();
7117            assert_eq!(
7118                matching.len(),
7119                1,
7120                "upsert must produce exactly one entry per uid, got {}",
7121                matching.len()
7122            );
7123            assert!(matching[0].deleted, "tombstone must win over prior live");
7124        }
7125
7126        // ----- JNI tier ----------------------------------------------------
7127
7128        /// JNI inline-parser path: `publishMarkerJni` decodes a
7129        /// JString into the same `parse_marker_publish_json` helper
7130        /// the typed UniFFI path skips. Builds a JSON envelope shaped
7131        /// exactly like the consumer's marker serializer produces on
7132        /// the wire and verifies every field lands in the resulting
7133        /// `MarkerInfo`. Locks the duplicated codec — same pattern as
7134        /// `publish_json_inline_parser_extracts_battery_and_heart` in
7135        /// [`node_tests`], same rationale (silent field drop on
7136        /// the publish path).
7137        #[test]
7138        fn publish_json_inline_parser_extracts_live_marker_fields() {
7139            let json = r#"{
7140                "uid": "marker-jni-001",
7141                "type": "a-f-G-U-C",
7142                "lat": 33.71576,
7143                "lon": -84.41152,
7144                "hae": 312.4,
7145                "ts": 1700000000000,
7146                "callsign": "ALPHA-1",
7147                "color": -65536,
7148                "cell_id": "BRAVO"
7149            }"#;
7150
7151            let parsed = parse_marker_publish_json("", json).expect("parse");
7152
7153            assert_eq!(parsed.uid, "marker-jni-001");
7154            assert_eq!(parsed.marker_type, "a-f-G-U-C");
7155            assert_eq!(parsed.lat, 33.71576);
7156            assert_eq!(parsed.lon, -84.41152);
7157            assert_eq!(parsed.hae, Some(312.4));
7158            assert_eq!(parsed.callsign.as_deref(), Some("ALPHA-1"));
7159            assert_eq!(parsed.color, Some(-65536));
7160            assert_eq!(parsed.cell_id.as_deref(), Some("BRAVO"));
7161            assert!(!parsed.deleted);
7162        }
7163
7164        /// JNI tombstone inline-parser path: `publishMarkerJni` must
7165        /// accept the stripped tombstone body the consumer's deletion
7166        /// serializer produces (uid + `_deleted: true` + ts, no
7167        /// geo/type/callsign). Catches a regression where the parser
7168        /// tightens up its required-fields validation in a way that
7169        /// breaks the deletion path silently.
7170        #[test]
7171        fn publish_json_inline_parser_accepts_stripped_tombstone() {
7172            let json = r#"{"uid":"marker-jni-tomb-001","_deleted":true,"ts":1700000000000}"#;
7173            let parsed = parse_marker_publish_json("", json).expect("parse stripped tombstone");
7174            assert!(parsed.deleted);
7175            assert_eq!(parsed.uid, "marker-jni-tomb-001");
7176            assert_eq!(parsed.ts, 1_700_000_000_000);
7177            assert_eq!(
7178                parsed.marker_type, TOMBSTONE_PLACEHOLDER_TYPE,
7179                "absent type must resolve to the named placeholder, not a magic literal"
7180            );
7181        }
7182
7183        // ----- JNI + UniFFI: storage round-trip via the get-side serializer
7184        //       (the shape getMarkersJni hands to consumers) -------------
7185
7186        /// `getMarkersJni` serializes `Vec<MarkerInfo>` via
7187        /// `serialize_markers_get_json` — the JSON shape consumers
7188        /// parse. A round-trip test pins that the wire shape
7189        /// `get_markers` emits is one a subsequent
7190        /// `parse_marker_publish_json` accepts, ensuring no
7191        /// asymmetric-codec regression slips through.
7192        #[test]
7193        fn get_markers_jni_serialized_shape_re_parses_cleanly() {
7194            let node = make_node("getjni");
7195            node.put_marker(live_marker("marker-getjni-001"))
7196                .expect("put live");
7197            node.put_marker(tombstone_marker("marker-getjni-002"))
7198                .expect("put tomb");
7199
7200            let listed = node.get_markers().expect("get_markers");
7201            let json = serialize_markers_get_json(&listed);
7202
7203            // Decode every entry through the same inline parser the
7204            // publish path uses. If the get-side shape ever diverges
7205            // from the publish-side shape, this fails before it
7206            // reaches a consumer.
7207            let arr: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
7208            for obj in arr.as_array().expect("array").iter() {
7209                let body = serde_json::to_string(obj).unwrap();
7210                let parsed = parse_marker_publish_json("", &body).expect("get-side body re-parses");
7211                if parsed.uid == "marker-getjni-002" {
7212                    assert!(parsed.deleted, "tombstone preserved in scan output");
7213                } else {
7214                    assert!(!parsed.deleted, "live preserved in scan output");
7215                }
7216            }
7217        }
7218    }
7219}
7220
7221// =============================================================================
7222// JNI Bindings - Direct Android native method support
7223// =============================================================================
7224//
7225// These functions provide a direct JNI interface that bypasses JNA's symbol
7226// lookup issues on Android. When System.loadLibrary() is called, these
7227// functions are registered via JNI's standard naming convention.
7228//
7229// Usage in Kotlin:
7230// ```kotlin
7231// class PeatJni {
7232//     companion object {
7233//         init {
7234//             System.loadLibrary("peat_ffi")
7235//         }
7236//     }
7237//     external fun peatVersion(): String
7238//     external fun testJni(): String
7239// }
7240// ```
7241
7242/// JNI: Get Peat library version
7243///
7244/// Kotlin signature: external fun peatVersion(): String
7245#[no_mangle]
7246pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_peatVersion(
7247    mut env: JNIEnv,
7248    _class: JClass,
7249) -> jstring {
7250    let version = peat_version();
7251    env.new_string(&version)
7252        .expect("Failed to create Java string")
7253        .into_raw()
7254}
7255
7256/// Pinned `GlobalRef` to the Android Context jobject that
7257/// `setAndroidContextJni` last received. The raw pointer we hand to
7258/// `ndk_context::initialize_android_context` is the jobject handle
7259/// inside this GlobalRef; the JVM guarantees the handle remains
7260/// valid (and pointing at the same Java object even if the GC moves
7261/// the underlying heap object) until the GlobalRef is dropped.
7262///
7263/// Storing the GlobalRef in a `Mutex<Option<GlobalRef>>` (rather
7264/// than a `OnceLock`) supports the documented call pattern: the
7265/// surface admits multiple `setAndroidContextJni` invocations, but
7266/// **only before `createNodeJni` starts iroh** (see that fn's
7267/// docstring). The mutex serializes concurrent
7268/// `setAndroidContextJni` callers; it does NOT block readers of
7269/// `ndk_context::android_context()`. Between the
7270/// `release_android_context()` and `initialize_android_context()`
7271/// calls inside `setAndroidContextJni` there is a brief window where
7272/// the global cell is empty — any iroh worker thread that hits
7273/// `android_context()` during that window panics. The pre-iroh-start
7274/// constraint makes the window structurally unreachable in
7275/// practice (no iroh worker exists yet) but a re-init after
7276/// `createNodeJni` is unsafe.
7277#[cfg(target_os = "android")]
7278static ANDROID_CONTEXT_GLOBAL_REF: std::sync::Mutex<Option<jni::objects::GlobalRef>> =
7279    std::sync::Mutex::new(None);
7280
7281/// Set to `true` by `createNodeJni` (and `createNodeWithConfigJni`)
7282/// on first successful node construction; checked by
7283/// `setAndroidContextJni` to reject post-iroh-start invocations.
7284///
7285/// Why this exists: `setAndroidContextJni` must release and
7286/// reinitialize `ndk-context`'s global cell, and there is a brief
7287/// window between the two calls where any iroh worker thread
7288/// reaching `ndk_context::android_context()` panics. The
7289/// `Application.onCreate`-before-`createNodeJni` call pattern keeps
7290/// the window structurally unreachable (no iroh worker exists yet),
7291/// but the Kotlin/Rust doc could be ignored by a consumer that
7292/// re-acquires the Application Context in `onActivityResult` or
7293/// similar. This flag turns that misuse into a logged-and-ignored
7294/// no-op rather than a SIGABRT.
7295///
7296/// One-way: once set, never cleared. Re-init is unsafe by design;
7297/// there is no recovery path. Set via `Release` to publish all
7298/// prior writes (iroh handle install, tokio runtime startup) to any
7299/// `Acquire` reader; checked via `Acquire` to see them. peat#924 QA
7300/// WARNING-2 round 2.
7301#[cfg(target_os = "android")]
7302static IROH_STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
7303
7304/// JNI: Plumb the Android `Context` jobject into `ndk-context`'s
7305/// global cell.
7306///
7307/// Kotlin signature: `external fun setAndroidContextJni(context: Any)`
7308///
7309/// Why this exists: `JNI_OnLoad` initializes `ndk-context` with the
7310/// `JavaVM*` it receives as an argument, but passes `null` for the
7311/// Android `Context` because no `Context` exists yet — `JNI_OnLoad`
7312/// runs before any `Application` has been instantiated by the
7313/// framework. That's enough for the iroh discovery subtree
7314/// (swarm-discovery / mDNS) which only needs the JVM for thread
7315/// attachment. It is NOT enough for code that needs the
7316/// `Context` itself — `hickory-resolver`'s Android `ConnectivityManager`
7317/// probe (transitively reachable via iroh-dns), NDK asset-manager
7318/// access, app-private file path resolution, etc. Those paths panic
7319/// with `android context was not initialized` on first call.
7320///
7321/// Consumers using iroh DNS-based discovery (relay, pkarr,
7322/// non-mDNS peer lookups) MUST call this from
7323/// `Application.onCreate()` passing the application Context BEFORE
7324/// the first `createNodeJni`. Consumers using only mDNS local-link
7325/// discovery (peat-ffi's own surface tests, the QUICKSTART
7326/// scenarios 1–3) can skip it.
7327///
7328/// Multiple calls are allowed, but **only before `createNodeJni`**
7329/// is invoked. Calling this after iroh has started creates a brief
7330/// window between `release_android_context()` and
7331/// `initialize_android_context()` where any concurrent
7332/// `ndk_context::android_context()` reader — iroh-dns
7333/// `hickory-resolver`'s ConnectivityManager probe, the mDNS
7334/// multicast worker, etc. — sees the cell empty and panics with
7335/// "android context was not initialized". The mutex protecting
7336/// `ANDROID_CONTEXT_GLOBAL_REF` serializes concurrent
7337/// `setAndroidContextJni` writers but does NOT block readers
7338/// reaching into `ndk-context`'s own global cell. The
7339/// Application.onCreate-before-createNodeJni call pattern makes
7340/// the window structurally unreachable (no iroh worker exists
7341/// yet); a re-init after iroh starts is unsafe.
7342///
7343/// The JVM pointer remains the same one JNI_OnLoad stored on every
7344/// call; only the Context changes. peat#925 QA WARNING follow-up.
7345#[cfg(target_os = "android")]
7346#[no_mangle]
7347pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_setAndroidContextJni(
7348    env: JNIEnv,
7349    _class: JClass,
7350    context: jni::objects::JObject,
7351) {
7352    // Reject post-iroh-start invocations. The release+reinit pair
7353    // below has a brief window where `ndk_context::android_context()`
7354    // returns the empty cell — once any iroh worker is alive (i.e.
7355    // `createNodeJni` has returned successfully), one of them
7356    // resolving the cell during that window panics. The documented
7357    // call pattern (Application.onCreate before any createNodeJni)
7358    // makes the window unreachable; this `Acquire` load is the
7359    // runtime guardrail for misuse that ignores the doc. peat#924 QA
7360    // WARNING-2 round 2.
7361    use std::sync::atomic::Ordering;
7362    if IROH_STARTED.load(Ordering::Acquire) {
7363        android_log(
7364            "setAndroidContextJni: ignoring — iroh already started; \
7365             call this from Application.onCreate BEFORE createNodeJni. \
7366             See PeatJni.kt KDoc.",
7367        );
7368        return;
7369    }
7370
7371    // JNI delivers `context` as a **local reference** — valid only
7372    // for the duration of this native method call. After we return,
7373    // the JVM is free to recycle the local-ref table slot, and a
7374    // raw pointer to it would alias the wrong (or no) object on the
7375    // next `ndk_context::android_context().context()` lookup.
7376    // Promote to a process-lifetime global reference first, then
7377    // hand `ndk_context` the jobject handle from inside the
7378    // GlobalRef. peat#925 QA WARNING-2.
7379    let global_ref = match env.new_global_ref(&context) {
7380        Ok(gref) => gref,
7381        Err(e) => {
7382            android_log(&format!(
7383                "setAndroidContextJni: env.new_global_ref(context) failed: {}",
7384                e
7385            ));
7386            return;
7387        }
7388    };
7389    let vm_ptr = match env.get_java_vm() {
7390        Ok(vm) => vm.get_java_vm_pointer() as *mut c_void,
7391        Err(_) => {
7392            android_log("setAndroidContextJni: env.get_java_vm() failed");
7393            return;
7394        }
7395    };
7396
7397    // SAFETY: `JNI_OnLoad` cached the JavaVM and called
7398    // `ndk_context::initialize_android_context(vm, null)` exactly
7399    // once at library-load time. `ndk-context 0.1.1` is one-shot —
7400    // calling `initialize_android_context` a second time asserts on
7401    // `previous.is_none()` and SIGABRT's the process (peat#925 QA
7402    // d2d01b23 surface-test surfaced this). The documented re-init
7403    // pattern is `release_android_context()` followed by a fresh
7404    // `initialize_android_context(...)`. We do exactly that here,
7405    // holding the `ANDROID_CONTEXT_GLOBAL_REF` mutex across the pair
7406    // so concurrent `setAndroidContextJni` callers serialize and
7407    // neither sees the cell in a released-but-not-yet-reinitialized
7408    // state. The JavaVM pointer remains the same one JNI_OnLoad
7409    // stored; only the Context changes (from `null` to the
7410    // GlobalRef'd jobject handle on first call; from the previous
7411    // GlobalRef to the new one on subsequent calls).
7412    //
7413    // The jobject handle is pulled from `global_ref.as_raw()` — the
7414    // JVM guarantees this remains valid until the GlobalRef is
7415    // dropped, which we prevent by stashing the GlobalRef in
7416    // `ANDROID_CONTEXT_GLOBAL_REF` below before releasing the lock.
7417    let ctx_ptr = global_ref.as_raw() as *mut c_void;
7418    let mut slot = ANDROID_CONTEXT_GLOBAL_REF.lock().unwrap();
7419    unsafe {
7420        // `release_android_context()` asserts `previous.is_some()`
7421        // — safe because JNI_OnLoad installed the `(vm, null)` entry
7422        // exactly once and this critical section is the only place
7423        // in peat-ffi that releases. If we ever surface a
7424        // `clear_android_context_jni`, it would also need the same
7425        // mutex.
7426        ndk_context::release_android_context();
7427        ndk_context::initialize_android_context(vm_ptr, ctx_ptr);
7428    }
7429    // Replace the cell *after* the ndk_context swap. The drop of
7430    // the previous GlobalRef happens here (out of the Option). The
7431    // new GlobalRef is now the one keeping `ctx_ptr` live.
7432    *slot = Some(global_ref);
7433    drop(slot);
7434
7435    android_log(
7436        "setAndroidContextJni: ndk_context re-initialized with non-null Context (GlobalRef pinned)",
7437    );
7438}
7439
7440/// JNI: Returns whether `ndk-context`'s stored Context is non-null
7441/// — i.e., whether a prior `setAndroidContextJni` call has wired a
7442/// real Application Context into the global cell.
7443///
7444/// Kotlin signature: `external fun verifyAndroidContextJni(): Boolean`
7445///
7446/// Surface-tier test hook (peat#925 QA BLOCKER). Lets an
7447/// instrumented Android test assert end-to-end that
7448/// Kotlin → JNI → Rust → `ndk_context` actually wired the Context
7449/// through, without having to drive a downstream consumer (e.g.,
7450/// hickory-resolver's Android system-DNS probe) just to verify
7451/// the plumbing. Production code should not call this — the
7452/// information is internal to the wiring contract.
7453#[cfg(target_os = "android")]
7454#[no_mangle]
7455pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_verifyAndroidContextJni(
7456    _env: JNIEnv,
7457    _class: JClass,
7458) -> jni::sys::jboolean {
7459    let stored = ndk_context::android_context().context();
7460    if stored.is_null() {
7461        jni::sys::JNI_FALSE
7462    } else {
7463        jni::sys::JNI_TRUE
7464    }
7465}
7466
7467/// JNI: Test that JNI bindings work
7468///
7469/// Kotlin signature: external fun testJni(): String
7470#[no_mangle]
7471pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_testJni(
7472    mut env: JNIEnv,
7473    _class: JClass,
7474) -> jstring {
7475    let msg = "JNI bindings working! Peat FFI loaded successfully.";
7476    env.new_string(msg)
7477        .expect("Failed to create Java string")
7478        .into_raw()
7479}
7480
7481/// JNI: Create a Peat node (simplified for testing)
7482///
7483/// Kotlin signature: external fun createNodeJni(appId: String, sharedKey:
7484/// String, storagePath: String): Long
7485#[cfg(feature = "sync")]
7486#[no_mangle]
7487pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_createNodeJni(
7488    mut env: JNIEnv,
7489    _class: JClass,
7490    app_id: JString,
7491    shared_key: JString,
7492    storage_path: JString,
7493) -> i64 {
7494    let app_id: String = match env.get_string(&app_id) {
7495        Ok(s) => s.into(),
7496        Err(_) => return 0,
7497    };
7498    let shared_key: String = match env.get_string(&shared_key) {
7499        Ok(s) => s.into(),
7500        Err(_) => return 0,
7501    };
7502    let storage_path: String = match env.get_string(&storage_path) {
7503        Ok(s) => s.into(),
7504        Err(_) => return 0,
7505    };
7506
7507    #[cfg(target_os = "android")]
7508    android_log(&format!(
7509        "createNodeJni: app_id={}, storage_path={}",
7510        app_id, storage_path
7511    ));
7512
7513    let config = NodeConfig {
7514        app_id,
7515        shared_key,
7516        bind_address: None,
7517        storage_path,
7518        transport: None,
7519    };
7520
7521    match create_node(config) {
7522        Ok(node) => {
7523            #[cfg(target_os = "android")]
7524            android_log("createNodeJni: Node created successfully");
7525            // Publish "iroh has started" to any future
7526            // `setAndroidContextJni` reader BEFORE handing the
7527            // handle back to Kotlin. `Release` here pairs with
7528            // `Acquire` in setAndroidContextJni — guarantees all
7529            // writes leading up to this point (iroh handle install,
7530            // tokio runtime startup, iroh worker spawn) are visible
7531            // to a setAndroidContextJni call that observes the flag
7532            // set. One-way: never cleared, even on `freeNodeJni` —
7533            // re-issuing setAndroidContextJni after a node lifecycle
7534            // is still unsafe because iroh tokio workers may
7535            // outlive the node handle.
7536            #[cfg(target_os = "android")]
7537            IROH_STARTED.store(true, std::sync::atomic::Ordering::Release);
7538            // Return the Arc pointer as a handle
7539            // Store an OWNING reference in the global (survives APK
7540            // replacement) BEFORE consuming `node` into the JNI handle, so the
7541            // global owns its own ref rather than aliasing the handle. Released
7542            // by clearGlobalNodeHandleJni, independent of this handle's
7543            // freeNodeJni. See set_global_node_handle.
7544            set_global_node_handle(&node);
7545            let handle = Arc::into_raw(node) as i64;
7546            #[cfg(target_os = "android")]
7547            android_log(&format!("createNodeJni: Stored global handle: {}", handle));
7548            handle
7549        }
7550        Err(e) => {
7551            #[cfg(target_os = "android")]
7552            android_log(&format!("createNodeJni: Error creating node: {:?}", e));
7553            0
7554        }
7555    }
7556}
7557
7558/// JNI: Create a PeatNode with transport configuration (ADR-039, #558)
7559///
7560/// This extended version supports BLE transport configuration for unified
7561/// multi-transport operation. When enable_ble is true, the node will attempt
7562/// to initialize BLE transport alongside the default Iroh transport.
7563///
7564/// Note: On Android, BLE transport requires the Android BLE adapter to be
7565/// initialized via JNI callbacks. Full BLE support is pending Android adapter
7566/// integration in peat-btle.
7567///
7568/// Kotlin signature:
7569/// ```kotlin
7570/// external fun createNodeWithConfigJni(
7571///     appId: String,
7572///     sharedKey: String,
7573///     storagePath: String,
7574///     enableBle: Boolean,
7575///     blePowerProfile: String?  // "aggressive", "balanced", or "low_power"
7576/// ): Long
7577/// ```
7578#[cfg(feature = "sync")]
7579#[no_mangle]
7580pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_createNodeWithConfigJni(
7581    mut env: JNIEnv,
7582    _class: JClass,
7583    app_id: JString,
7584    shared_key: JString,
7585    storage_path: JString,
7586    enable_ble: jboolean,
7587    ble_power_profile: JString,
7588) -> i64 {
7589    let app_id: String = match env.get_string(&app_id) {
7590        Ok(s) => s.into(),
7591        Err(_) => return 0,
7592    };
7593    let shared_key: String = match env.get_string(&shared_key) {
7594        Ok(s) => s.into(),
7595        Err(_) => return 0,
7596    };
7597    let storage_path: String = match env.get_string(&storage_path) {
7598        Ok(s) => s.into(),
7599        Err(_) => return 0,
7600    };
7601
7602    // Parse BLE power profile (null/empty string means use default)
7603    let power_profile: Option<String> = env.get_string(&ble_power_profile).ok().and_then(|s| {
7604        let s: String = s.into();
7605        if s.is_empty() {
7606            None
7607        } else {
7608            Some(s)
7609        }
7610    });
7611
7612    #[cfg(target_os = "android")]
7613    android_log(&format!(
7614        "createNodeWithConfigJni: app_id={}, storage_path={}, enable_ble={}, power_profile={:?}",
7615        app_id,
7616        storage_path,
7617        enable_ble != 0,
7618        power_profile
7619    ));
7620
7621    // Build transport configuration
7622    let transport_config = if enable_ble != 0 {
7623        Some(TransportConfigFFI {
7624            enable_ble: true,
7625            ble_mesh_id: None, // Use app_id as mesh ID
7626            ble_power_profile: power_profile,
7627            transport_preference: None,
7628            collection_routes_json: None,
7629        })
7630    } else {
7631        None
7632    };
7633
7634    let config = NodeConfig {
7635        app_id,
7636        shared_key,
7637        bind_address: None,
7638        storage_path,
7639        transport: transport_config,
7640    };
7641
7642    match create_node(config) {
7643        Ok(node) => {
7644            #[cfg(target_os = "android")]
7645            android_log("createNodeWithConfigJni: Node created successfully");
7646            // Publish iroh-started — same Release/Acquire pairing
7647            // with setAndroidContextJni as in createNodeJni above.
7648            // peat#924 QA WARNING-2.
7649            #[cfg(target_os = "android")]
7650            IROH_STARTED.store(true, std::sync::atomic::Ordering::Release);
7651            // Owning global ref before consuming `node` (see set_global_node_handle).
7652            set_global_node_handle(&node);
7653            let handle = Arc::into_raw(node) as i64;
7654            #[cfg(target_os = "android")]
7655            android_log(&format!(
7656                "createNodeWithConfigJni: Stored global handle: {}",
7657                handle
7658            ));
7659            handle
7660        }
7661        Err(e) => {
7662            #[cfg(target_os = "android")]
7663            android_log(&format!(
7664                "createNodeWithConfigJni: Error creating node: {:?}",
7665                e
7666            ));
7667            0
7668        }
7669    }
7670}
7671
7672/// JNI: Get the global node handle (survives APK replacement)
7673///
7674/// Kotlin signature: external fun getGlobalNodeHandleJni(): Long
7675#[cfg(feature = "sync")]
7676#[no_mangle]
7677pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getGlobalNodeHandleJni(
7678    _env: JNIEnv,
7679    _class: JClass,
7680) -> i64 {
7681    match GLOBAL_NODE_HANDLE.lock() {
7682        Ok(handle) => {
7683            let h = *handle;
7684            #[cfg(target_os = "android")]
7685            android_log(&format!("getGlobalNodeHandleJni: Returning handle: {}", h));
7686            h
7687        }
7688        Err(_) => 0,
7689    }
7690}
7691
7692/// JNI: Release the owning reference stored in [`GLOBAL_NODE_HANDLE`].
7693///
7694/// Counterpart to the `set_global_node_handle` write performed by every
7695/// node-create path. The bridge that consumes `getGlobalNodeHandleJni` (e.g.
7696/// the BLE pipe) calls this on teardown so the node can actually be freed
7697/// once its originating handle is also released. Safe to call repeatedly and
7698/// when no handle is stored (no-op on `0`).
7699///
7700/// Kotlin signature: `external fun clearGlobalNodeHandleJni()`
7701#[cfg(feature = "sync")]
7702#[no_mangle]
7703pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_clearGlobalNodeHandleJni(
7704    _env: JNIEnv,
7705    _class: JClass,
7706) {
7707    clear_owning_node_slot(&GLOBAL_NODE_HANDLE);
7708}
7709
7710/// JNI: Get node ID from a PeatNode handle
7711///
7712/// Kotlin signature: external fun nodeIdJni(handle: Long): String
7713#[cfg(feature = "sync")]
7714#[no_mangle]
7715pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_nodeIdJni(
7716    mut env: JNIEnv,
7717    _class: JClass,
7718    handle: i64,
7719) -> jstring {
7720    if handle == 0 {
7721        return env
7722            .new_string("")
7723            .expect("Failed to create Java string")
7724            .into_raw();
7725    }
7726
7727    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7728    let node_id = node.node_id();
7729
7730    // Don't drop the Arc - we're just borrowing
7731    std::mem::forget(node);
7732
7733    env.new_string(&node_id)
7734        .expect("Failed to create Java string")
7735        .into_raw()
7736}
7737
7738/// JNI: Get peer count from a PeatNode handle
7739///
7740/// Kotlin signature: external fun peerCountJni(handle: Long): Int
7741#[cfg(feature = "sync")]
7742#[no_mangle]
7743pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_peerCountJni(
7744    _env: JNIEnv,
7745    _class: JClass,
7746    handle: i64,
7747) -> i32 {
7748    if handle == 0 {
7749        return 0;
7750    }
7751
7752    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7753    let count = node.peer_count() as i32;
7754
7755    // Don't drop the Arc - we're just borrowing
7756    std::mem::forget(node);
7757
7758    count
7759}
7760
7761/// JNI: Request full document sync with all connected peers
7762///
7763/// Kotlin signature: external fun requestSyncJni(handle: Long): Boolean
7764#[cfg(feature = "sync")]
7765#[no_mangle]
7766pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_requestSyncJni(
7767    _env: JNIEnv,
7768    _class: JClass,
7769    handle: i64,
7770) -> jboolean {
7771    if handle == 0 {
7772        return 0;
7773    }
7774    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7775    let result = node.request_sync().is_ok();
7776    std::mem::forget(node);
7777    result as jboolean
7778}
7779
7780/// JNI: Get this node's iroh-endpoint first IP socket address as an
7781/// `"ip:port"` string, or null if no socket is bound. The result is
7782/// what `connectPeerJni` expects as its `address` argument when one
7783/// in-process instance dials another on loopback (no discovery layer
7784/// to populate it). peat-mesh#138 M4.
7785///
7786/// Kotlin signature: external fun endpointSocketAddrJni(handle: Long): String?
7787#[cfg(feature = "sync")]
7788#[no_mangle]
7789pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_endpointSocketAddrJni(
7790    env: JNIEnv,
7791    _class: JClass,
7792    handle: i64,
7793) -> jstring {
7794    if handle == 0 {
7795        return std::ptr::null_mut();
7796    }
7797    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7798    let addr = node.endpoint_socket_addr();
7799    std::mem::forget(node);
7800    match addr {
7801        Some(s) => env
7802            .new_string(s)
7803            .map(|js| js.into_raw())
7804            .unwrap_or(std::ptr::null_mut()),
7805        None => std::ptr::null_mut(),
7806    }
7807}
7808
7809/// Serialize a `peat_mesh::Document` back into the JSON-object shape
7810/// the consumer originally posted via `publishDocumentJni`. The
7811/// publish path hoists an `"id"` field to `Document::id`; this
7812/// helper reinserts it so the round-trip preserves the consumer's
7813/// input shape. Extracted from `getDocumentJni`'s body so the
7814/// serialization can be exercised by an in-crate test independent
7815/// of a JVM (peat#879 QA round 2 — surface-tier coverage for the
7816/// JSON output path).
7817#[cfg(feature = "sync")]
7818fn serialize_document_for_get_jni(doc: &peat_mesh::sync::Document) -> String {
7819    let mut obj = serde_json::Map::new();
7820    for (k, v) in &doc.fields {
7821        obj.insert(k.clone(), v.clone());
7822    }
7823    if let Some(id) = &doc.id {
7824        obj.insert("id".to_string(), serde_json::Value::String(id.clone()));
7825    }
7826    serde_json::Value::Object(obj).to_string()
7827}
7828
7829/// JNI: Read a document back from the local store as JSON, or null
7830/// if the document doesn't exist locally. Complements
7831/// `publishDocumentJni` — needed by instrumented tests that verify
7832/// sync convergence by reading on the receiver side. peat-mesh#138 M4.
7833///
7834/// Kotlin signature: external fun getDocumentJni(handle: Long, collection:
7835/// String, docId: String): String?
7836#[cfg(feature = "sync")]
7837#[no_mangle]
7838pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getDocumentJni(
7839    mut env: JNIEnv,
7840    _class: JClass,
7841    handle: i64,
7842    collection: JString,
7843    doc_id: JString,
7844) -> jstring {
7845    if handle == 0 {
7846        return std::ptr::null_mut();
7847    }
7848    // peat#885 fault-injection short-circuit, consumed before any
7849    // store interaction. `swap(false, ...)` is one-shot — the next
7850    // call returns to the normal read path. Test-only by API
7851    // contract; production callers never arm the flag.
7852    if FORCE_STORE_ERROR_FOR_TESTING.swap(false, std::sync::atomic::Ordering::SeqCst) {
7853        let _ = env.throw_new(
7854            "java/lang/RuntimeException",
7855            "getDocumentJni: forced store error (test fault injection)",
7856        );
7857        return std::ptr::null_mut();
7858    }
7859    let collection_str: String = match env.get_string(&collection) {
7860        Ok(s) => s.into(),
7861        Err(_) => return std::ptr::null_mut(),
7862    };
7863    let doc_id_str: String = match env.get_string(&doc_id) {
7864        Ok(s) => s.into(),
7865        Err(_) => return std::ptr::null_mut(),
7866    };
7867    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
7868    let mesh_node = Arc::clone(&node_owner.node);
7869    let runtime = Arc::clone(&node_owner.runtime);
7870    std::mem::forget(node_owner);
7871
7872    // Read through the same `peat_mesh::Node` document layer that
7873    // `publishDocumentJni` writes to. The older raw-bytes
7874    // `PeatNode::get_document` reads from a different storage path
7875    // (`storage_backend.collection(...)`) and won't see docs that
7876    // arrived via the document layer's publish or that sync replicas
7877    // applied as Automerge ops. peat-mesh#138 M4 / peat#879 QA.
7878    let result = runtime.block_on(mesh_node.get(&collection_str, &doc_id_str));
7879    match result {
7880        Ok(Some(doc)) => {
7881            let json = serialize_document_for_get_jni(&doc);
7882            env.new_string(json)
7883                .map(|js| js.into_raw())
7884                .unwrap_or(std::ptr::null_mut())
7885        }
7886        Ok(None) => std::ptr::null_mut(),
7887        Err(e) => {
7888            // Distinguish "store read failed" from "not present"
7889            // (peat#879 QA WARNING) — silent null on Err would mask
7890            // hard storage errors as ongoing sync-not-converged, and
7891            // the consumer would spin until timeout. Throw across the
7892            // JNI boundary so the caller sees a fail-fast exception
7893            // with the underlying cause.
7894            let msg = format!("getDocumentJni: store read failed: {e}");
7895            let _ = env.throw_new("java/lang/RuntimeException", &msg);
7896            std::ptr::null_mut()
7897        }
7898    }
7899}
7900
7901/// JNI: Test-only fault injection. Arms a one-shot flag so the next
7902/// `getDocumentJni` call short-circuits to the Err branch (throws
7903/// RuntimeException) without touching the underlying store. Self-
7904/// clears on consumption.
7905///
7906/// Exists so consumers can write a deterministic regression test for
7907/// the `getDocumentJni` `Err(_) → env.throw_new` contract without
7908/// depending on Automerge LRU eviction behavior. See peat#885 /
7909/// peat-mesh#138 M4b carryover.
7910///
7911/// Returns 1 on success, 0 if the handle is invalid.
7912///
7913/// Kotlin signature: external fun forceStoreErrorForTestingJni(handle: Long):
7914/// Boolean
7915#[cfg(feature = "sync")]
7916#[no_mangle]
7917pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_forceStoreErrorForTestingJni(
7918    _env: JNIEnv,
7919    _class: JClass,
7920    handle: i64,
7921) -> jboolean {
7922    if handle == 0 {
7923        return 0;
7924    }
7925    FORCE_STORE_ERROR_FOR_TESTING.store(true, std::sync::atomic::Ordering::SeqCst);
7926    1
7927}
7928
7929/// JNI: Get connected peer IDs as a JSON array
7930///
7931/// Kotlin signature: external fun connectedPeersJni(handle: Long): String
7932/// Returns JSON array of hex-encoded peer IDs, or "[]" on error
7933#[cfg(feature = "sync")]
7934#[no_mangle]
7935pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_connectedPeersJni(
7936    mut env: JNIEnv,
7937    _class: JClass,
7938    handle: i64,
7939) -> jstring {
7940    if handle == 0 {
7941        return env
7942            .new_string("[]")
7943            .expect("Failed to create Java string")
7944            .into_raw();
7945    }
7946
7947    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7948    let peers = node.connected_peers();
7949    let result = serde_json::to_string(&peers).unwrap_or_else(|_| "[]".to_string());
7950
7951    // Don't drop the Arc - we're just borrowing
7952    std::mem::forget(node);
7953
7954    env.new_string(&result)
7955        .expect("Failed to create Java string")
7956        .into_raw()
7957}
7958
7959/// JNI: Start sync on a PeatNode
7960///
7961/// Kotlin signature: external fun startSyncJni(handle: Long): Boolean
7962#[cfg(feature = "sync")]
7963#[no_mangle]
7964pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_startSyncJni(
7965    _env: JNIEnv,
7966    _class: JClass,
7967    handle: i64,
7968) -> bool {
7969    // CRITICAL DEBUG: Log unconditionally to verify this function is called
7970    eprintln!("startSyncJni: CALLED with handle={}", handle);
7971    #[cfg(target_os = "android")]
7972    android_log(&format!("startSyncJni: ENTERED with handle={}", handle));
7973
7974    if handle == 0 {
7975        #[cfg(target_os = "android")]
7976        android_log("startSyncJni: handle is 0, returning false");
7977        return false;
7978    }
7979
7980    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
7981
7982    #[cfg(target_os = "android")]
7983    android_log("startSyncJni: calling node.start_sync()");
7984
7985    let result = match node.start_sync() {
7986        Ok(()) => {
7987            #[cfg(target_os = "android")]
7988            android_log("startSyncJni: start_sync succeeded");
7989            true
7990        }
7991        Err(e) => {
7992            #[cfg(target_os = "android")]
7993            android_log(&format!("startSyncJni: start_sync failed: {}", e));
7994            false
7995        }
7996    };
7997
7998    // Don't drop the Arc - we're just borrowing
7999    std::mem::forget(node);
8000
8001    result
8002}
8003
8004/// JNI: Free a PeatNode handle
8005///
8006/// Kotlin signature: external fun freeNodeJni(handle: Long)
8007#[cfg(feature = "sync")]
8008#[no_mangle]
8009pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_freeNodeJni(
8010    _env: JNIEnv,
8011    _class: JClass,
8012    handle: i64,
8013) {
8014    if handle != 0 {
8015        #[cfg(target_os = "android")]
8016        android_log(&format!("freeNodeJni: Freeing node handle {}", handle));
8017
8018        // Reconstruct the Arc to drop it
8019        let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8020
8021        // Signal the cleanup task to stop
8022        node.cleanup_running.store(false, Ordering::SeqCst);
8023
8024        #[cfg(target_os = "android")]
8025        android_log("freeNodeJni: Signaled cleanup task to stop");
8026
8027        // Give the background task a moment to exit
8028        std::thread::sleep(std::time::Duration::from_millis(100));
8029
8030        // Clear Android BLE transport global to prevent dangling refs
8031        #[cfg(all(feature = "bluetooth", target_os = "android"))]
8032        {
8033            *ANDROID_BLE_TRANSPORT.lock().unwrap() = None;
8034            android_log("freeNodeJni: Cleared ANDROID_BLE_TRANSPORT");
8035        }
8036
8037        // Drop the node - this should release the database
8038        drop(node);
8039
8040        #[cfg(target_os = "android")]
8041        android_log("freeNodeJni: Node dropped");
8042    }
8043}
8044
8045// =============================================================================
8046// BLE Transport JNI Methods (Android)
8047// =============================================================================
8048
8049/// JNI: Signal BLE transport started/stopped
8050///
8051/// Called by Kotlin when the Android BLE stack is ready or shutting down.
8052/// This makes `is_available()` return true/false for PACE routing.
8053///
8054/// Kotlin signature: external fun bleSetStartedJni(handle: Long, started:
8055/// Boolean)
8056#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8057#[no_mangle]
8058pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleSetStartedJni(
8059    _env: JNIEnv,
8060    _class: JClass,
8061    handle: i64,
8062    started: jboolean,
8063) {
8064    if handle == 0 {
8065        android_log("bleSetStartedJni: Invalid handle (0)");
8066        return;
8067    }
8068
8069    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8070
8071    use peat_protocol::transport::MeshTransport;
8072
8073    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8074    if let Some(ref ble_transport) = *guard {
8075        if started != 0 {
8076            match node.runtime.block_on(ble_transport.start()) {
8077                Ok(()) => android_log("bleSetStartedJni: BLE transport started"),
8078                Err(e) => android_log(&format!("bleSetStartedJni: start failed: {}", e)),
8079            }
8080        } else {
8081            match node.runtime.block_on(ble_transport.stop()) {
8082                Ok(()) => android_log("bleSetStartedJni: BLE transport stopped"),
8083                Err(e) => android_log(&format!("bleSetStartedJni: stop failed: {}", e)),
8084            }
8085        }
8086    } else {
8087        android_log("bleSetStartedJni: No BLE transport registered");
8088    }
8089    drop(guard);
8090
8091    // Don't drop the Arc - we're just borrowing
8092    std::mem::forget(node);
8093}
8094
8095/// JNI: Add a reachable BLE peer
8096///
8097/// Called by Kotlin when a BLE peer is discovered/connected.
8098/// This makes `can_reach(peer)` return true for PACE routing.
8099///
8100/// Kotlin signature: external fun bleAddPeerJni(handle: Long, peerId: String)
8101#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8102#[no_mangle]
8103pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleAddPeerJni(
8104    mut env: JNIEnv,
8105    _class: JClass,
8106    handle: i64,
8107    peer_id: JString,
8108) {
8109    if handle == 0 {
8110        android_log("bleAddPeerJni: Invalid handle (0)");
8111        return;
8112    }
8113
8114    let peer_id_str: String = match env.get_string(&peer_id) {
8115        Ok(s) => s.into(),
8116        Err(_) => {
8117            android_log("bleAddPeerJni: Failed to get peer_id string");
8118            return;
8119        }
8120    };
8121
8122    android_log(&format!("bleAddPeerJni: Adding peer {}", peer_id_str));
8123
8124    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8125    if let Some(ref ble_transport) = *guard {
8126        use peat_protocol::transport::NodeId;
8127        ble_transport.add_reachable_peer(NodeId::new(peer_id_str));
8128    } else {
8129        android_log("bleAddPeerJni: No BLE transport registered");
8130    }
8131}
8132
8133/// JNI: Remove a reachable BLE peer
8134///
8135/// Called by Kotlin when a BLE peer is disconnected/lost.
8136/// This makes `can_reach(peer)` return false for PACE routing.
8137///
8138/// Kotlin signature: external fun bleRemovePeerJni(handle: Long, peerId:
8139/// String)
8140#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8141#[no_mangle]
8142pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleRemovePeerJni(
8143    mut env: JNIEnv,
8144    _class: JClass,
8145    handle: i64,
8146    peer_id: JString,
8147) {
8148    if handle == 0 {
8149        android_log("bleRemovePeerJni: Invalid handle (0)");
8150        return;
8151    }
8152
8153    let peer_id_str: String = match env.get_string(&peer_id) {
8154        Ok(s) => s.into(),
8155        Err(_) => {
8156            android_log("bleRemovePeerJni: Failed to get peer_id string");
8157            return;
8158        }
8159    };
8160
8161    android_log(&format!("bleRemovePeerJni: Removing peer {}", peer_id_str));
8162
8163    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8164    if let Some(ref ble_transport) = *guard {
8165        use peat_protocol::transport::NodeId;
8166        ble_transport.remove_reachable_peer(&NodeId::new(peer_id_str));
8167    } else {
8168        android_log("bleRemovePeerJni: No BLE transport registered");
8169    }
8170}
8171
8172/// JNI: Query whether BLE transport is available (started)
8173///
8174/// Called by Kotlin to check if BLE transport is active for UI display.
8175/// Returns true if BLE transport has been started via bleSetStartedJni.
8176///
8177/// Kotlin signature: external fun bleIsAvailableJni(handle: Long): Boolean
8178#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8179#[no_mangle]
8180pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_bleIsAvailableJni(
8181    _env: JNIEnv,
8182    _class: JClass,
8183    handle: i64,
8184) -> jboolean {
8185    if handle == 0 {
8186        android_log("bleIsAvailableJni: Invalid handle (0)");
8187        return 0;
8188    }
8189
8190    use peat_protocol::transport::Transport;
8191
8192    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8193    let result = match guard.as_ref() {
8194        Some(t) => {
8195            if t.is_available() {
8196                1
8197            } else {
8198                0
8199            }
8200        }
8201        None => 0,
8202    };
8203
8204    android_log(&format!("bleIsAvailableJni: {}", result != 0));
8205    result
8206}
8207
8208/// JNI: Get the number of reachable BLE peers
8209///
8210/// Called by Kotlin to get BLE peer count for unified UI display.
8211/// Returns the number of peers added via bleAddPeerJni.
8212///
8213/// Kotlin signature: external fun blePeerCountJni(handle: Long): Int
8214#[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
8215#[no_mangle]
8216pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blePeerCountJni(
8217    _env: JNIEnv,
8218    _class: JClass,
8219    handle: i64,
8220) -> jint {
8221    if handle == 0 {
8222        android_log("blePeerCountJni: Invalid handle (0)");
8223        return 0;
8224    }
8225
8226    let guard = ANDROID_BLE_TRANSPORT.lock().unwrap();
8227    let count = match guard.as_ref() {
8228        Some(t) => t.reachable_peer_count() as jint,
8229        None => 0,
8230    };
8231
8232    android_log(&format!("blePeerCountJni: {}", count));
8233    count
8234}
8235
8236/// JNI: Get all cells as JSON array string
8237///
8238/// Kotlin signature: external fun getCellsJni(handle: Long): String
8239/// Returns JSON array of cell objects, or "[]" on error
8240#[cfg(feature = "sync")]
8241#[no_mangle]
8242pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getCellsJni(
8243    mut env: JNIEnv,
8244    _class: JClass,
8245    handle: i64,
8246) -> jstring {
8247    if handle == 0 {
8248        return env
8249            .new_string("[]")
8250            .expect("Failed to create Java string")
8251            .into_raw();
8252    }
8253
8254    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8255    let result = match node.get_cells() {
8256        Ok(cells) => {
8257            let json_array: Vec<serde_json::Value> = cells
8258                .iter()
8259                .map(|c| {
8260                    serde_json::json!({
8261                        "id": c.id,
8262                        "name": c.name,
8263                        "status": c.status.as_str(),
8264                        "node_count": c.node_count,
8265                        "center_lat": c.center_lat,
8266                        "center_lon": c.center_lon,
8267                        "capabilities": c.capabilities,
8268                        "formation_id": c.formation_id,
8269                        "leader_id": c.leader_id,
8270                        "last_update": c.last_update,
8271                        "scenario_command": c.scenario_command,
8272                    })
8273                })
8274                .collect();
8275            serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
8276        }
8277        Err(_) => "[]".to_string(),
8278    };
8279
8280    // Don't drop the Arc - we're just borrowing
8281    std::mem::forget(node);
8282
8283    env.new_string(&result)
8284        .expect("Failed to create Java string")
8285        .into_raw()
8286}
8287
8288/// JNI: Get all tracks as JSON array string
8289///
8290/// Kotlin signature: external fun getTracksJni(handle: Long): String
8291/// Returns JSON array of track objects, or "[]" on error
8292#[cfg(feature = "sync")]
8293#[no_mangle]
8294pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getTracksJni(
8295    mut env: JNIEnv,
8296    _class: JClass,
8297    handle: i64,
8298) -> jstring {
8299    if handle == 0 {
8300        return env
8301            .new_string("[]")
8302            .expect("Failed to create Java string")
8303            .into_raw();
8304    }
8305
8306    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8307    let result = match node.get_tracks() {
8308        Ok(tracks) => {
8309            let json_array: Vec<serde_json::Value> = tracks
8310                .iter()
8311                .map(|t| {
8312                    serde_json::json!({
8313                        "id": t.id,
8314                        "source_node": t.source_node,
8315                        "cell_id": t.cell_id,
8316                        "formation_id": t.formation_id,
8317                        "lat": t.lat,
8318                        "lon": t.lon,
8319                        "hae": t.hae,
8320                        "cep": t.cep,
8321                        "heading": t.heading,
8322                        "speed": t.speed,
8323                        "classification": t.classification,
8324                        "confidence": t.confidence,
8325                        "category": t.category.as_str(),
8326                        "created_at": t.created_at,
8327                        "last_update": t.last_update,
8328                        "attributes": t.attributes,
8329                    })
8330                })
8331                .collect();
8332            serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
8333        }
8334        Err(_) => "[]".to_string(),
8335    };
8336
8337    // Don't drop the Arc - we're just borrowing
8338    std::mem::forget(node);
8339
8340    env.new_string(&result)
8341        .expect("Failed to create Java string")
8342        .into_raw()
8343}
8344
8345/// JNI: Get all nodes as JSON array string
8346///
8347/// Kotlin signature: external fun getNodesJni(handle: Long): String
8348/// Returns JSON array of node objects, or "[]" on error
8349#[cfg(feature = "sync")]
8350#[no_mangle]
8351pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getNodesJni(
8352    mut env: JNIEnv,
8353    _class: JClass,
8354    handle: i64,
8355) -> jstring {
8356    if handle == 0 {
8357        return env
8358            .new_string("[]")
8359            .expect("Failed to create Java string")
8360            .into_raw();
8361    }
8362
8363    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8364    let result = match node.get_nodes() {
8365        Ok(nodes) => serialize_nodes_get_json(&nodes),
8366        Err(_) => "[]".to_string(),
8367    };
8368
8369    // Don't drop the Arc - we're just borrowing
8370    std::mem::forget(node);
8371
8372    env.new_string(&result)
8373        .expect("Failed to create Java string")
8374        .into_raw()
8375}
8376
8377/// JNI: Get all commands as JSON array string
8378///
8379/// Kotlin signature: external fun getCommandsJni(handle: Long): String
8380/// Returns JSON array of command objects, or "[]" on error
8381#[cfg(feature = "sync")]
8382#[no_mangle]
8383pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getCommandsJni(
8384    mut env: JNIEnv,
8385    _class: JClass,
8386    handle: i64,
8387) -> jstring {
8388    if handle == 0 {
8389        return env
8390            .new_string("[]")
8391            .expect("Failed to create Java string")
8392            .into_raw();
8393    }
8394
8395    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8396    let result = match node.get_commands() {
8397        Ok(commands) => {
8398            let json_array: Vec<serde_json::Value> = commands
8399                .iter()
8400                .map(|c| {
8401                    serde_json::json!({
8402                        "id": c.id,
8403                        "command_type": c.command_type,
8404                        "target_id": c.target_id,
8405                        "parameters": c.parameters,
8406                        "priority": c.priority,
8407                        "status": c.status.as_str(),
8408                        "originator": c.originator,
8409                        "created_at": c.created_at,
8410                        "last_update": c.last_update,
8411                    })
8412                })
8413                .collect();
8414            serde_json::to_string(&json_array).unwrap_or_else(|_| "[]".to_string())
8415        }
8416        Err(_) => "[]".to_string(),
8417    };
8418
8419    // Don't drop the Arc - we're just borrowing
8420    std::mem::forget(node);
8421
8422    env.new_string(&result)
8423        .expect("Failed to create Java string")
8424        .into_raw()
8425}
8426
8427/// JNI: Publish a node (self-position/PLI) to the Peat network
8428///
8429/// Kotlin signature: external fun publishNodeJni(handle: Long, nodeJson:
8430/// String): Boolean Stores the node in the "nodes" collection for sync to
8431/// peers.
8432///
8433/// Expected JSON format:
8434/// ```json
8435/// {
8436///   "id": "consumer-device-uid",
8437///   "name": "CALLSIGN",
8438///   "node_type": "SOLDIER",
8439///   "lat": 33.7490,
8440///   "lon": -84.3880,
8441///   "hae": 320.0,
8442///   "heading": 45.0,
8443///   "speed": 1.5,
8444///   "status": "ACTIVE",
8445///   "capabilities": ["PLI"],
8446///   "cell_id": null,
8447///   "readiness": 1.0
8448/// }
8449/// ```
8450#[cfg(feature = "sync")]
8451#[no_mangle]
8452pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishNodeJni(
8453    mut env: JNIEnv,
8454    _class: JClass,
8455    handle: i64,
8456    node_json: JString,
8457) -> jboolean {
8458    if handle == 0 {
8459        #[cfg(target_os = "android")]
8460        android_log("publishNodeJni: Invalid handle (0)");
8461        return 0; // JNI_FALSE
8462    }
8463
8464    // Get node JSON string from Java
8465    let json_str: String = match env.get_string(&node_json) {
8466        Ok(s) => s.into(),
8467        Err(e) => {
8468            #[cfg(target_os = "android")]
8469            android_log(&format!(
8470                "publishNodeJni: Failed to get JSON string: {:?}",
8471                e
8472            ));
8473            return 0; // JNI_FALSE
8474        }
8475    };
8476
8477    #[cfg(target_os = "android")]
8478    android_log(&format!("publishNodeJni: Received JSON: {}", json_str));
8479
8480    // Parse JSON via the shared helper so the test suite exercises the
8481    // same code the JNI surface does. Pre-2026-05-08 this was inlined
8482    // here, which made it a duplicated codec the unit tests didn't
8483    // reach — the silent-field-drop bug class peat#835 exists to lock
8484    // in came in through this exact site.
8485    let node: NodeInfo = match parse_node_publish_json(&json_str) {
8486        Ok(p) => p,
8487        Err(e) => {
8488            #[cfg(target_os = "android")]
8489            android_log(&format!("publishNodeJni: {}", e));
8490            return 0; // JNI_FALSE
8491        }
8492    };
8493
8494    #[cfg(target_os = "android")]
8495    android_log(&format!(
8496        "publishNodeJni: Publishing node id={}, name={}, lat={}, lon={}",
8497        node.id, node.name, node.lat, node.lon
8498    ));
8499
8500    // Get node from handle and store node
8501    let peat_node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8502    let result = match peat_node.put_node(node) {
8503        Ok(_) => {
8504            #[cfg(target_os = "android")]
8505            android_log("publishNodeJni: Node published successfully");
8506            1 // JNI_TRUE
8507        }
8508        Err(e) => {
8509            #[cfg(target_os = "android")]
8510            android_log(&format!("publishNodeJni: Failed to publish: {:?}", e));
8511            0 // JNI_FALSE
8512        }
8513    };
8514
8515    // Don't drop the Arc - we're just borrowing
8516    std::mem::forget(peat_node);
8517
8518    result
8519}
8520
8521/// JNI: Get all markers as JSON array string
8522///
8523/// Kotlin signature: `external fun getMarkersJni(handle: Long): String`
8524/// Returns JSON array of marker objects, or `"[]"` on error.
8525#[cfg(feature = "sync")]
8526#[no_mangle]
8527pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_getMarkersJni(
8528    mut env: JNIEnv,
8529    _class: JClass,
8530    handle: i64,
8531) -> jstring {
8532    if handle == 0 {
8533        return env
8534            .new_string("[]")
8535            .expect("Failed to create Java string")
8536            .into_raw();
8537    }
8538
8539    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8540    let result = match node.get_markers() {
8541        Ok(markers) => serialize_markers_get_json(&markers),
8542        Err(e) => {
8543            // Surface storage failures the same way the publish
8544            // side does — otherwise Kotlin sees `"[]"` and can't
8545            // tell "no markers" from "storage error retrieving
8546            // markers." Triage on a tablet starts with the
8547            // PeatFFI logcat tag; this line is what makes "marker
8548            // didn't sync" reports actionable.
8549            #[cfg(target_os = "android")]
8550            android_log(&format!("getMarkersJni: get_markers failed: {:?}", e));
8551            let _ = e;
8552            "[]".to_string()
8553        }
8554    };
8555
8556    // Don't drop the Arc - we're just borrowing
8557    std::mem::forget(node);
8558
8559    env.new_string(&result)
8560        .expect("Failed to create Java string")
8561        .into_raw()
8562}
8563
8564/// JNI: Publish a marker into the doc store. Routes through the
8565/// universal-Document transport on every registered radio
8566/// (LiteBridgeTranslator on BLE, iroh sync for cross-mesh peers).
8567///
8568/// Kotlin signature: `external fun publishMarkerJni(handle: Long, markerJson:
8569/// String): Boolean` Returns `1` (JNI_TRUE) on success, `0` (JNI_FALSE) on
8570/// failure (invalid handle, malformed JSON, missing required fields, storage
8571/// error). The Kotlin caller maps the boolean return back to a
8572/// success / "publish failed" log path — same shape as
8573/// `publishNodeJni`.
8574#[cfg(feature = "sync")]
8575#[no_mangle]
8576pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishMarkerJni(
8577    mut env: JNIEnv,
8578    _class: JClass,
8579    handle: i64,
8580    marker_json: JString,
8581) -> jboolean {
8582    if handle == 0 {
8583        #[cfg(target_os = "android")]
8584        android_log("publishMarkerJni: Invalid handle (0)");
8585        return 0;
8586    }
8587
8588    let json_str: String = match env.get_string(&marker_json) {
8589        Ok(s) => s.into(),
8590        Err(e) => {
8591            #[cfg(target_os = "android")]
8592            android_log(&format!(
8593                "publishMarkerJni: Failed to get JSON string: {:?}",
8594                e
8595            ));
8596            let _ = e;
8597            return 0;
8598        }
8599    };
8600
8601    #[cfg(target_os = "android")]
8602    android_log(&format!("publishMarkerJni: Received JSON: {}", json_str));
8603
8604    // Parse — uid is read from the body (no doc-store id available
8605    // pre-storage). parse_marker_publish_json's `id` parameter is
8606    // accepted for the scan-side path; on publish we pass the
8607    // body's uid and reject if absent.
8608    let marker: MarkerInfo = match parse_marker_publish_json("", &json_str) {
8609        Ok(m) => m,
8610        Err(e) => {
8611            #[cfg(target_os = "android")]
8612            android_log(&format!("publishMarkerJni: parse error: {:?}", e));
8613            let _ = e;
8614            return 0;
8615        }
8616    };
8617
8618    #[cfg(target_os = "android")]
8619    if marker.deleted {
8620        android_log(&format!(
8621            "publishMarkerJni: Publishing TOMBSTONE for uid={}",
8622            marker.uid
8623        ));
8624    } else {
8625        android_log(&format!(
8626            "publishMarkerJni: Publishing marker uid={}, type={}, lat={}, lon={}",
8627            marker.uid, marker.marker_type, marker.lat, marker.lon
8628        ));
8629    }
8630
8631    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
8632    let result = match node.put_marker(marker) {
8633        Ok(_) => {
8634            #[cfg(target_os = "android")]
8635            android_log("publishMarkerJni: Marker published successfully");
8636            1
8637        }
8638        Err(e) => {
8639            #[cfg(target_os = "android")]
8640            android_log(&format!("publishMarkerJni: Failed to publish: {:?}", e));
8641            let _ = e;
8642            0
8643        }
8644    };
8645
8646    std::mem::forget(node);
8647    result
8648}
8649
8650/// Publish a generic document into a named collection via `peat_mesh::Node`.
8651///
8652/// JNI wrapper around [`publish_document_into_node`]. The Kotlin caller passes
8653/// a JSON object; top-level keys become the document body. The `"id"` field
8654/// is optional — when present and a string, it becomes the document's id;
8655/// when absent or non-string, the backend assigns one (and returns it). The
8656/// returned Java string is the id that was actually used (caller-supplied or
8657/// backend-assigned), so callers needing a stable id must capture the return
8658/// value rather than assuming the input `"id"` won.
8659///
8660/// Returns an empty Java string on failure: handle invalid, JSON malformed,
8661/// JSON not an object, or backend publish error. Foundation step 3 of the
8662/// peat-mesh-completion work.
8663///
8664/// Kotlin signature: `external fun publishDocumentJni(handle: Long, collection:
8665/// String, json: String): String`
8666#[cfg(feature = "sync")]
8667#[no_mangle]
8668pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishDocumentJni(
8669    mut env: JNIEnv,
8670    _class: JClass,
8671    handle: i64,
8672    collection: JString,
8673    json: JString,
8674) -> jstring {
8675    // Track the result string we want to return; build the jstring at the
8676    // single env.new_string() call site at the end. Avoids the tangle of
8677    // borrowing `env` multiple times across short-circuit error returns.
8678    let result_str: String = if handle == 0 {
8679        #[cfg(target_os = "android")]
8680        android_log("publishDocumentJni: Invalid handle (0)");
8681        String::new()
8682    } else {
8683        match (env.get_string(&collection), env.get_string(&json)) {
8684            (Ok(c), Ok(j)) => {
8685                let collection_str: String = c.into();
8686                let json_str: String = j.into();
8687                // Borrow the node Arc without taking ownership — same
8688                // pattern as the other ..._Jni functions in this file.
8689                let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
8690                let mesh_node = Arc::clone(&node_owner.node);
8691                let runtime = Arc::clone(&node_owner.runtime);
8692                std::mem::forget(node_owner);
8693
8694                // clippy suggests `.unwrap_or_default()` but the Err
8695                // arm has a real side effect (android_log call) that
8696                // would be lost.
8697                #[allow(clippy::manual_unwrap_or_default)]
8698                match runtime.block_on(publish_document_into_node(
8699                    &mesh_node,
8700                    &collection_str,
8701                    &json_str,
8702                )) {
8703                    Ok(id) => id,
8704                    Err(_e) => {
8705                        #[cfg(target_os = "android")]
8706                        android_log(&format!("publishDocumentJni: publish failed: {}", _e));
8707                        String::new()
8708                    }
8709                }
8710            }
8711            (Err(_e), _) | (_, Err(_e)) => {
8712                #[cfg(target_os = "android")]
8713                android_log(&format!(
8714                    "publishDocumentJni: failed to read args: {:?}",
8715                    _e
8716                ));
8717                String::new()
8718            }
8719        }
8720    };
8721
8722    env.new_string(result_str)
8723        .map(|s| s.into_raw())
8724        .unwrap_or(std::ptr::null_mut())
8725}
8726
8727/// Origin-aware sibling of [`Java_..._publishDocumentJni`]
8728/// (ADR-059 Amendment 2 — Slice 1.b.4 host-side wiring).
8729///
8730/// Same body as `publishDocumentJni` plus an `origin` parameter that
8731/// flows through to [`peat_mesh::Node::publish_with_origin`]. The
8732/// plugin's `BleDecodedDocumentBridge` calls this with `origin="ble"`
8733/// after decoding a 0xB6 translator frame, so cross-transport fan-out's
8734/// loop-prevention skips the BLE channel on this node and the doc
8735/// doesn't re-emit back out the way it came.
8736///
8737/// Empty `origin` is treated as `None` (equivalent to plain
8738/// `publishDocumentJni`); any non-empty string is passed through
8739/// verbatim. peat-mesh validates the origin against the registered
8740/// transport set; an unknown origin produces a publish-time error
8741/// (logged + empty return string).
8742///
8743/// Kotlin signature: `external fun publishDocumentWithOriginJni(handle: Long,
8744/// collection: String, json: String, origin: String): String`
8745#[cfg(feature = "sync")]
8746#[no_mangle]
8747pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_publishDocumentWithOriginJni(
8748    mut env: JNIEnv,
8749    _class: JClass,
8750    handle: i64,
8751    collection: JString,
8752    json: JString,
8753    origin: JString,
8754) -> jstring {
8755    let result_str: String = if handle == 0 {
8756        #[cfg(target_os = "android")]
8757        android_log("publishDocumentWithOriginJni: Invalid handle (0)");
8758        String::new()
8759    } else {
8760        match (
8761            env.get_string(&collection),
8762            env.get_string(&json),
8763            env.get_string(&origin),
8764        ) {
8765            (Ok(c), Ok(j), Ok(o)) => {
8766                let collection_str: String = c.into();
8767                let json_str: String = j.into();
8768                let origin_str: String = o.into();
8769                let origin_opt = if origin_str.is_empty() {
8770                    None
8771                } else {
8772                    Some(origin_str)
8773                };
8774                let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
8775                let mesh_node = Arc::clone(&node_owner.node);
8776                let runtime = Arc::clone(&node_owner.runtime);
8777                std::mem::forget(node_owner);
8778
8779                #[allow(clippy::manual_unwrap_or_default)]
8780                match runtime.block_on(publish_document_into_node_with_origin(
8781                    &mesh_node,
8782                    &collection_str,
8783                    &json_str,
8784                    origin_opt,
8785                )) {
8786                    Ok(id) => id,
8787                    Err(_e) => {
8788                        #[cfg(target_os = "android")]
8789                        android_log(&format!(
8790                            "publishDocumentWithOriginJni: publish failed: {}",
8791                            _e
8792                        ));
8793                        String::new()
8794                    }
8795                }
8796            }
8797            // Per-position match preserves the underlying JNI error in
8798            // the diagnostic, matching `publishDocumentJni`'s shape. A
8799            // wildcard arm would drop `_e` and obscure plugin-side
8800            // debugging when one of the three string args is malformed.
8801            (Err(_e), _, _) | (_, Err(_e), _) | (_, _, Err(_e)) => {
8802                #[cfg(target_os = "android")]
8803                android_log(&format!(
8804                    "publishDocumentWithOriginJni: failed to read args: {:?}",
8805                    _e
8806                ));
8807                String::new()
8808            }
8809        }
8810    };
8811
8812    env.new_string(result_str)
8813        .map(|s| s.into_raw())
8814        .unwrap_or(std::ptr::null_mut())
8815}
8816
8817/// Pure-Rust helper backing [`Java_..._publishDocumentJni`]. Parses a JSON
8818/// object into a [`peat_mesh::sync::types::Document`] (the `"id"` string
8819/// field, if present, becomes [`Document::id`]; remaining keys land in
8820/// [`Document::fields`]) and publishes it into the given collection on the
8821/// node. Exposed for unit tests so the conversion + publish path can be
8822/// exercised without spinning up a JVM.
8823#[cfg(feature = "sync")]
8824async fn publish_document_into_node(
8825    node: &peat_mesh::Node,
8826    collection: &str,
8827    json: &str,
8828) -> anyhow::Result<String> {
8829    publish_document_into_node_with_origin(node, collection, json, None).await
8830}
8831
8832/// Origin-aware sibling of [`publish_document_into_node`], backing
8833/// [`Java_..._publishDocumentWithOriginJni`] (ADR-059 Amendment 2 Slice
8834/// 1.b.4). When `origin` is `Some(_)`, publishes via
8835/// [`peat_mesh::Node::publish_with_origin`] so cross-transport fan-out's
8836/// loop-prevention skips the named origin transport — required for the
8837/// plugin's `BleDecodedDocumentBridge` to ingest 0xB6 frames into the
8838/// doc store without re-emitting them back out to BLE. With `None` this
8839/// behaves identically to a plain `publish`. Exposed for unit tests so
8840/// the parse + publish-with-origin path can be exercised without a JVM.
8841#[cfg(feature = "sync")]
8842async fn publish_document_into_node_with_origin(
8843    node: &peat_mesh::Node,
8844    collection: &str,
8845    json: &str,
8846    origin: Option<String>,
8847) -> anyhow::Result<String> {
8848    use peat_mesh::sync::types::Document;
8849    use serde_json::Value;
8850
8851    let value: Value =
8852        serde_json::from_str(json).map_err(|e| anyhow::anyhow!("invalid JSON: {}", e))?;
8853
8854    let mut obj = match value {
8855        Value::Object(map) => map,
8856        other => {
8857            return Err(anyhow::anyhow!(
8858                "document JSON must be an object, got {:?}",
8859                other
8860            ))
8861        }
8862    };
8863
8864    let id = obj.remove("id").and_then(|v| match v {
8865        Value::String(s) => Some(s),
8866        _ => None,
8867    });
8868
8869    let fields = obj.into_iter().collect();
8870    let document = match id {
8871        Some(id) => Document::with_id(id, fields),
8872        None => Document::new(fields),
8873    };
8874
8875    match origin {
8876        Some(o) => {
8877            node.publish_with_origin(collection, document, Some(o))
8878                .await
8879        }
8880        None => node.publish(collection, document).await,
8881    }
8882}
8883
8884/// Ingest a peat-btle [`BlePosition`]-shaped JSON envelope: translate it
8885/// to an Automerge track document via [`BleTranslator`] and publish into
8886/// [`peat_mesh::Node`] with `Some("ble")` origin (ADR-059). From there
8887/// iroh-bound peers receive the doc through Automerge sync; the origin
8888/// rides on the resulting `ChangeEvent` so `TransportManager`'s fan-out
8889/// suppresses the same-node `BLE → Node → observer → BLE` echo.
8890///
8891/// JSON envelope (matches the `BlePosition` field shape plus the surrounding
8892/// metadata the translator needs):
8893/// ```json
8894/// {
8895///   "lat": 40.7,
8896///   "lon": -74.0,
8897///   "altitude": 100.0,        // optional
8898///   "accuracy": 5.0,          // optional
8899///   "peripheral_id": 3405643777,
8900///   "callsign": "SCOUT-CAFE", // optional
8901///   "mesh_id": "29C916FA"     // optional
8902/// }
8903/// ```
8904///
8905/// `peripheral_id` accepts the full u32 range expressed two ways: as a
8906/// non-negative integer (Kotlin `Long`/`UInt` paths) or as a sign-extended
8907/// negative integer (Kotlin `Int.toLong()` of a u32 with the high bit set —
8908/// e.g. `0xCAFE_0001` reads as `-889323519` through a signed Int). Both forms
8909/// recover the same u32 internally; values above `u32::MAX` or below
8910/// `i32::MIN` are rejected rather than silently truncated. See
8911/// [`parse_peripheral_id`].
8912///
8913/// Kotlin signature: `external fun ingestPositionJni(handle: Long, json:
8914/// String): String`
8915///
8916/// Returns the assigned track-document id on success, or empty string on any
8917/// failure (handle invalid, bluetooth feature not built, JSON malformed,
8918/// missing required fields, peripheral_id out of range, publish error).
8919///
8920/// [`BleTranslator`]: peat_protocol::sync::ble_translation::BleTranslator
8921#[cfg(all(feature = "sync", feature = "bluetooth"))]
8922#[no_mangle]
8923pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestPositionJni(
8924    mut env: JNIEnv,
8925    _class: JClass,
8926    handle: i64,
8927    json: JString,
8928) -> jstring {
8929    let result_str: String = if handle == 0 {
8930        #[cfg(target_os = "android")]
8931        android_log("ingestPositionJni: Invalid handle (0)");
8932        String::new()
8933    } else {
8934        match env.get_string(&json) {
8935            Ok(j) => {
8936                let json_str: String = j.into();
8937                let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
8938                let translator = Arc::clone(&node_owner.ble_translator);
8939                let node = Arc::clone(&node_owner.node);
8940                let runtime = Arc::clone(&node_owner.runtime);
8941                std::mem::forget(node_owner);
8942
8943                // The Err arm has a side effect (android_log) that
8944                // `unwrap_or_default()` cannot preserve, so the `match`
8945                // is intentional. Keeping the lint silenced explicitly
8946                // mirrors the same decision in pre-Slice-1.b.2.2 code.
8947                #[allow(clippy::manual_unwrap_or_default)]
8948                match runtime.block_on(ingest_position_via_translator(
8949                    &translator,
8950                    &node,
8951                    &json_str,
8952                )) {
8953                    Ok(id) => id,
8954                    Err(_e) => {
8955                        #[cfg(target_os = "android")]
8956                        android_log(&format!("ingestPositionJni: ingest failed: {}", _e));
8957                        String::new()
8958                    }
8959                }
8960            }
8961            Err(_e) => {
8962                #[cfg(target_os = "android")]
8963                android_log(&format!("ingestPositionJni: failed to read json: {:?}", _e));
8964                String::new()
8965            }
8966        }
8967    };
8968
8969    env.new_string(result_str)
8970        .map(|s| s.into_raw())
8971        .unwrap_or(std::ptr::null_mut())
8972}
8973
8974/// JNI: Ingest an inbound frame received over BLE into the mesh.
8975///
8976/// Kotlin signature:
8977/// `external fun ingestInboundFrameJni(handle: Long, collection: String,
8978/// postcardBytes: ByteArray): String?`
8979///
8980/// Thin wrapper over [`PeatNode::ingest_inbound_frame`], which decodes the
8981/// frame via the `BleTranslator` and publishes it into the mesh tagged with
8982/// `Some("ble")` origin — so `TransportManager`'s per-transport fan-out
8983/// re-emits it to the OTHER transports (iroh / Wi-Fi) without looping back
8984/// to BLE (ADR-059). This is the inbound counterpart of
8985/// `subscribeOutboundFramesJni`: a Kotlin BLE manager calls this with each
8986/// decrypted frame it receives over the radio.
8987///
8988/// Returns the published document id, or null on failure / no-op (invalid
8989/// handle, byte/string marshaling error, or the translator produced no
8990/// document).
8991#[cfg(all(feature = "sync", feature = "bluetooth"))]
8992#[no_mangle]
8993pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestInboundFrameJni(
8994    mut env: JNIEnv,
8995    _class: JClass,
8996    handle: i64,
8997    collection: JString,
8998    postcard_bytes: JByteArray,
8999) -> jstring {
9000    if handle == 0 {
9001        #[cfg(target_os = "android")]
9002        android_log("ingestInboundFrameJni: Invalid handle (0)");
9003        return std::ptr::null_mut();
9004    }
9005    let collection_str: String = match env.get_string(&collection) {
9006        Ok(s) => s.into(),
9007        Err(_e) => {
9008            #[cfg(target_os = "android")]
9009            android_log(&format!(
9010                "ingestInboundFrameJni: failed to read collection: {:?}",
9011                _e
9012            ));
9013            return std::ptr::null_mut();
9014        }
9015    };
9016    let bytes: Vec<u8> = match env.convert_byte_array(&postcard_bytes) {
9017        Ok(b) => b,
9018        Err(_e) => {
9019            #[cfg(target_os = "android")]
9020            android_log(&format!(
9021                "ingestInboundFrameJni: failed to read bytes: {:?}",
9022                _e
9023            ));
9024            return std::ptr::null_mut();
9025        }
9026    };
9027
9028    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
9029    let result = node_owner.ingest_inbound_frame(collection_str, bytes);
9030    std::mem::forget(node_owner);
9031
9032    match result {
9033        Ok(Some(id)) => env
9034            .new_string(id)
9035            .map(|s| s.into_raw())
9036            .unwrap_or(std::ptr::null_mut()),
9037        Ok(None) => std::ptr::null_mut(),
9038        Err(_e) => {
9039            #[cfg(target_os = "android")]
9040            android_log(&format!("ingestInboundFrameJni: ingest failed: {}", _e));
9041            std::ptr::null_mut()
9042        }
9043    }
9044}
9045
9046/// JNI: Ingest an inbound BLE frame on the universal-Document (peat-lite /
9047/// `ble-lite`) codec — the counterpart of `ingestInboundFrameJni` for raw
9048/// collections the typed translator declines (e.g. the `demo` counter).
9049///
9050/// Kotlin signature:
9051/// `external fun ingestInboundLiteFrameJni(handle: Long, collection: String,
9052/// envelopeBytes: ByteArray): String?`
9053#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
9054#[no_mangle]
9055pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestInboundLiteFrameJni(
9056    mut env: JNIEnv,
9057    _class: JClass,
9058    handle: i64,
9059    collection: JString,
9060    envelope_bytes: JByteArray,
9061) -> jstring {
9062    if handle == 0 {
9063        #[cfg(target_os = "android")]
9064        android_log("ingestInboundLiteFrameJni: Invalid handle (0)");
9065        return std::ptr::null_mut();
9066    }
9067    let collection_str: String = match env.get_string(&collection) {
9068        Ok(s) => s.into(),
9069        Err(_e) => {
9070            #[cfg(target_os = "android")]
9071            android_log(&format!(
9072                "ingestInboundLiteFrameJni: failed to read collection: {:?}",
9073                _e
9074            ));
9075            return std::ptr::null_mut();
9076        }
9077    };
9078    let bytes: Vec<u8> = match env.convert_byte_array(&envelope_bytes) {
9079        Ok(b) => b,
9080        Err(_e) => {
9081            #[cfg(target_os = "android")]
9082            android_log(&format!(
9083                "ingestInboundLiteFrameJni: failed to read bytes: {:?}",
9084                _e
9085            ));
9086            return std::ptr::null_mut();
9087        }
9088    };
9089
9090    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
9091    let result = node_owner.ingest_inbound_lite_frame(collection_str, bytes);
9092    std::mem::forget(node_owner);
9093
9094    match result {
9095        Ok(Some(id)) => env
9096            .new_string(id)
9097            .map(|s| s.into_raw())
9098            .unwrap_or(std::ptr::null_mut()),
9099        Ok(None) => std::ptr::null_mut(),
9100        Err(_e) => {
9101            #[cfg(target_os = "android")]
9102            android_log(&format!("ingestInboundLiteFrameJni: ingest failed: {}", _e));
9103            std::ptr::null_mut()
9104        }
9105    }
9106}
9107
9108/// JNI: ingest an inbound CRDT-counter frame (CRDT-over-Automerge-over-BLE).
9109///
9110/// `hex_bytes` is the UTF-8 hex of the shared Automerge doc's `save()` bytes —
9111/// the payload of a `0xAF` frame whose transport byte is `2` (crdt). Merges it
9112/// into the shared counter (idempotent/commutative) and returns the new value,
9113/// or -1 on error. Operates on the SAME `PeatNode` Dart created (the global
9114/// handle is an owning alias), so Dart's `crdtCounterValue()` sees the result.
9115///
9116/// Routes by `collection`: `"supply"` merges the Counter (returns the new
9117/// value); any other collection merges the generic CRDT KV doc (returns 0).
9118/// Returns -1 on error.
9119///
9120/// Kotlin: `external fun ingestCrdtFrameJni(handle: Long, collection: String,
9121/// hexBytes: ByteArray): Long`
9122#[cfg(all(feature = "sync", feature = "bluetooth"))]
9123#[no_mangle]
9124pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_ingestCrdtFrameJni(
9125    mut env: JNIEnv,
9126    _class: JClass,
9127    handle: i64,
9128    collection: JString,
9129    hex_bytes: JByteArray,
9130) -> i64 {
9131    if handle == 0 {
9132        return -1;
9133    }
9134    let collection_str: String = match env.get_string(&collection) {
9135        Ok(s) => s.into(),
9136        Err(_) => return -1,
9137    };
9138    let bytes: Vec<u8> = match env.convert_byte_array(&hex_bytes) {
9139        Ok(b) => b,
9140        Err(_) => return -1,
9141    };
9142    let hex = match String::from_utf8(bytes) {
9143        Ok(s) => s,
9144        Err(_) => return -1,
9145    };
9146    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
9147    let v = if collection_str == "supply" {
9148        node_owner.crdt_counter_merge(hex)
9149    } else {
9150        node_owner.crdt_kv_merge(collection_str, hex);
9151        0
9152    };
9153    std::mem::forget(node_owner);
9154    v
9155}
9156
9157/// Pure-Rust helper backing [`Java_..._ingestPositionJni`]. Parses the JSON
9158/// envelope into a [`BlePosition`] plus the surrounding ingest metadata,
9159/// translates to an Automerge document via [`BleTranslator`], and publishes
9160/// into [`peat_mesh::Node`] with `Some("ble")` origin per ADR-059. Exposed
9161/// for unit tests so the parse + translate + publish path can be exercised
9162/// without spinning up a JVM.
9163///
9164/// Hand-rolled JSON parsing rather than `#[derive(Deserialize)]` because
9165/// peat-ffi does not currently depend on `serde` directly (only
9166/// `serde_json`); adding it just for one private marshaling struct isn't
9167/// worth a Cargo.toml change and a fresh transitive footprint.
9168///
9169/// [`BlePosition`]: peat_protocol::sync::ble_translation::BlePosition
9170/// [`BleTranslator`]: peat_protocol::sync::ble_translation::BleTranslator
9171#[cfg(all(feature = "sync", feature = "bluetooth"))]
9172async fn ingest_position_via_translator(
9173    translator: &peat_protocol::sync::ble_translation::BleTranslator,
9174    node: &peat_mesh::Node,
9175    json: &str,
9176) -> anyhow::Result<String> {
9177    use peat_protocol::sync::ble_translation::{value_to_mesh_document, BlePosition};
9178    use serde_json::Value;
9179
9180    let value: Value = serde_json::from_str(json)
9181        .map_err(|e| anyhow::anyhow!("invalid ingest-position JSON: {}", e))?;
9182    let obj = value
9183        .as_object()
9184        .ok_or_else(|| anyhow::anyhow!("ingest-position JSON must be an object"))?;
9185
9186    let lat = obj
9187        .get("lat")
9188        .and_then(Value::as_f64)
9189        .ok_or_else(|| anyhow::anyhow!("ingest-position: missing or non-numeric `lat`"))?
9190        as f32;
9191    let lon = obj
9192        .get("lon")
9193        .and_then(Value::as_f64)
9194        .ok_or_else(|| anyhow::anyhow!("ingest-position: missing or non-numeric `lon`"))?
9195        as f32;
9196    let peripheral_id = parse_peripheral_id(obj.get("peripheral_id"))?;
9197
9198    let altitude = obj
9199        .get("altitude")
9200        .and_then(Value::as_f64)
9201        .map(|v| v as f32);
9202    let accuracy = obj
9203        .get("accuracy")
9204        .and_then(Value::as_f64)
9205        .map(|v| v as f32);
9206    let callsign = obj
9207        .get("callsign")
9208        .and_then(Value::as_str)
9209        .map(str::to_string);
9210    let mesh_id = obj
9211        .get("mesh_id")
9212        .and_then(Value::as_str)
9213        .map(str::to_string);
9214
9215    let position = BlePosition {
9216        latitude: lat,
9217        longitude: lon,
9218        altitude,
9219        accuracy,
9220    };
9221
9222    // Translate, then publish through Node::publish_with_origin so the
9223    // `Some("ble")` origin rides on the resulting ChangeEvent — without
9224    // it, TransportManager fan-out cannot break the BLE-loop on this
9225    // node (ADR-059 §"Origin propagation through async observer
9226    // pipelines").
9227    let value = translator.position_to_track_in_cell(
9228        &position,
9229        peripheral_id,
9230        callsign.as_deref(),
9231        mesh_id.as_deref(),
9232    );
9233    let doc = value_to_mesh_document(value);
9234    node.publish_with_origin(translator.tracks_collection(), doc, Some("ble".to_string()))
9235        .await
9236}
9237
9238/// Parse a `peripheral_id` JSON value into a `u32`, accepting both the
9239/// positive form (Kotlin `Long` / `UInt`) and the sign-extended-Int form
9240/// (Kotlin `Int.toLong()` of a value with the high bit set, which serializes
9241/// as a negative JSON literal). Reinterprets the bits via `i32 as u32` for
9242/// the negative case so a watch with peripheral_id `0xCAFE_0001` round-trips
9243/// the same regardless of which Kotlin numeric type the caller used.
9244///
9245/// Rejects missing values, non-integer values, and values outside
9246/// `[i32::MIN, u32::MAX]` (above-u32::MAX would otherwise silently truncate
9247/// and collide distinct logical IDs onto the same translator-emitted track
9248/// id `ble-XXXXXXXX`, mis-attributing positions to peers — caught by PR
9249/// #804 round-1 review).
9250#[cfg(all(feature = "sync", feature = "bluetooth"))]
9251fn parse_peripheral_id(value: Option<&serde_json::Value>) -> anyhow::Result<u32> {
9252    let raw = value.and_then(serde_json::Value::as_i64).ok_or_else(|| {
9253        anyhow::anyhow!("ingest-position: missing or non-integer `peripheral_id`")
9254    })?;
9255
9256    if (0..=u32::MAX as i64).contains(&raw) {
9257        // Positive: Kotlin Long, UInt, or any numeric type that produced a
9258        // non-negative JSON literal. Direct cast — no truncation since we
9259        // bounded above.
9260        Ok(raw as u32)
9261    } else if (i32::MIN as i64..=-1).contains(&raw) {
9262        // Negative: Kotlin Int.toLong() of a u32 with the high bit set
9263        // (e.g. 0xCAFE_0001 = 3_405_643_777 stored in a signed Int reads as
9264        // -889_323_519). `as i32` preserves the bit pattern, then
9265        // `as u32` reinterprets — so the recovered u32 matches what the
9266        // caller's u32 originally was, before Kotlin's signed-Int coercion.
9267        Ok((raw as i32) as u32)
9268    } else {
9269        Err(anyhow::anyhow!(
9270            "ingest-position: `peripheral_id` {} out of u32 range \
9271             (accepts [i32::MIN, u32::MAX] to handle both Kotlin Int and Long callers)",
9272            raw
9273        ))
9274    }
9275}
9276
9277/// Connect to a known peer by node ID and address (bypasses mDNS).
9278///
9279/// Kotlin signature: external fun connectPeerJni(handle: Long, nodeId: String,
9280/// address: String): Boolean Used by the dual-transport test to connect Android
9281/// to rpi-ci2 over QUIC when mDNS is unreliable.
9282#[cfg(feature = "sync")]
9283#[no_mangle]
9284pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_connectPeerJni(
9285    mut env: JNIEnv,
9286    _class: JClass,
9287    handle: i64,
9288    node_id: JString,
9289    address: JString,
9290) -> jboolean {
9291    if handle == 0 {
9292        #[cfg(target_os = "android")]
9293        android_log("connectPeerJni: Invalid handle (0)");
9294        return 0;
9295    }
9296
9297    let node_id_str: String = match env.get_string(&node_id) {
9298        Ok(s) => s.into(),
9299        Err(e) => {
9300            #[cfg(target_os = "android")]
9301            android_log(&format!("connectPeerJni: Failed to get nodeId: {:?}", e));
9302            return 0;
9303        }
9304    };
9305
9306    let addr_str: String = match env.get_string(&address) {
9307        Ok(s) => s.into(),
9308        Err(e) => {
9309            #[cfg(target_os = "android")]
9310            android_log(&format!("connectPeerJni: Failed to get address: {:?}", e));
9311            return 0;
9312        }
9313    };
9314
9315    #[cfg(target_os = "android")]
9316    android_log(&format!(
9317        "connectPeerJni: Connecting to node={}, addr={}",
9318        node_id_str, addr_str
9319    ));
9320
9321    let peer_info = PeerInfo {
9322        name: "quic-peer".to_string(),
9323        node_id: node_id_str,
9324        addresses: vec![addr_str],
9325        relay_url: None,
9326    };
9327
9328    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
9329    let result = match node.connect_peer(peer_info) {
9330        Ok(()) => {
9331            #[cfg(target_os = "android")]
9332            android_log("connectPeerJni: Connected successfully");
9333            1
9334        }
9335        Err(e) => {
9336            #[cfg(target_os = "android")]
9337            android_log(&format!("connectPeerJni: Failed to connect: {:?}", e));
9338            0
9339        }
9340    };
9341
9342    std::mem::forget(node);
9343    result
9344}
9345
9346// =============================================================================
9347// Document Change Subscription (direct JNI path)
9348// =============================================================================
9349//
9350// This is the push-based equivalent of the UniFFI PeatNode::subscribe() API.
9351// We can't use UniFFI's version from Android plugin consumers because UniFFI
9352// 0.28's Kotlin backend generates callback interfaces that inherit from
9353// com.sun.jna.Callback, and JNA's function-pointer resolution fails under
9354// Android plugin-host linker namespace isolation (see the comment block at
9355// the top of the JNI Bindings section and ADR-059 for full context).
9356//
9357// The direct-JNI path uses the same JAVA_VM + GlobalRef + attach_current_thread
9358// pattern that notify_peer_event already uses for peer connectivity events.
9359// Only one subscription is supported at a time.
9360
9361/// JNI: Subscribe to document change notifications
9362///
9363/// Kotlin signature:
9364/// `external fun subscribeDocumentChangesJni(handle: Long, listener:
9365/// DocumentChangeListener): Boolean`
9366///
9367/// The listener receives `onChange(collection, docId)` for every document
9368/// upsert and `onError(message)` if the underlying broadcast channel lags or
9369/// closes. Calls from the Rust side happen on the tokio runtime thread owned by
9370/// the PeatNode; the listener must be safe to invoke from any thread (consumers
9371/// typically post back to a main-thread Handler before touching UI state).
9372///
9373/// Replacing an existing subscription is allowed: the previous listener's
9374/// GlobalRef is dropped and the new one takes over on the next event.
9375#[cfg(feature = "sync")]
9376#[no_mangle]
9377pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_subscribeDocumentChangesJni(
9378    mut env: JNIEnv,
9379    _class: JClass,
9380    handle: i64,
9381    listener: jni::objects::JObject,
9382) -> jboolean {
9383    use std::sync::atomic::Ordering;
9384
9385    if handle == 0 {
9386        #[cfg(target_os = "android")]
9387        android_log("subscribeDocumentChangesJni: Invalid handle (0)");
9388        return 0;
9389    }
9390
9391    // Stash the listener as a global reference so it survives across JNI
9392    // thread attaches and isn't GC'd out from under us.
9393    let listener_global = match env.new_global_ref(&listener) {
9394        Ok(g) => g,
9395        Err(e) => {
9396            #[cfg(target_os = "android")]
9397            android_log(&format!(
9398                "subscribeDocumentChangesJni: new_global_ref failed: {:?}",
9399                e
9400            ));
9401            return 0;
9402        }
9403    };
9404
9405    // Swap the listener in; drop any previous one.
9406    {
9407        let mut slot = DOCUMENT_CHANGE_LISTENER.lock().unwrap();
9408        *slot = Some(listener_global);
9409    }
9410
9411    // Signal the previous subscription task (if any) to exit before we start
9412    // a new one, then mark the new subscription active.
9413    DOCUMENT_SUBSCRIPTION_ACTIVE.store(false, Ordering::SeqCst);
9414    DOCUMENT_SUBSCRIPTION_ACTIVE.store(true, Ordering::SeqCst);
9415
9416    // Borrow the node without taking ownership of its Arc.
9417    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
9418    let store = Arc::clone(&node.store);
9419    let runtime = Arc::clone(&node.runtime);
9420    std::mem::forget(node);
9421
9422    runtime.spawn(async move {
9423        let mut rx = store.subscribe_to_changes();
9424        while DOCUMENT_SUBSCRIPTION_ACTIVE.load(Ordering::SeqCst) {
9425            tokio::select! {
9426                result = rx.recv() => {
9427                    match result {
9428                        Ok(doc_key) => {
9429                            let (collection, doc_id) = doc_key
9430                                .split_once(':')
9431                                .map(|(c, d)| (c.to_string(), d.to_string()))
9432                                .unwrap_or_else(|| ("default".to_string(), doc_key.clone()));
9433                            dispatch_document_change(&collection, &doc_id);
9434                        }
9435                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
9436                            dispatch_document_error(&format!("lagged {} messages", n));
9437                        }
9438                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
9439                            dispatch_document_error("change channel closed");
9440                            break;
9441                        }
9442                    }
9443                }
9444                _ = tokio::time::sleep(tokio::time::Duration::from_millis(200)) => {
9445                    // Periodic wake so we notice unsubscribe requests even
9446                    // when the broadcast channel is quiet.
9447                }
9448            }
9449        }
9450
9451        // On exit, drop the listener ref if we were the owning subscription.
9452        if !DOCUMENT_SUBSCRIPTION_ACTIVE.load(Ordering::SeqCst) {
9453            let mut slot = DOCUMENT_CHANGE_LISTENER.lock().unwrap();
9454            *slot = None;
9455        }
9456    });
9457
9458    1 // JNI_TRUE
9459}
9460
9461/// JNI: Unsubscribe from document change notifications
9462///
9463/// Kotlin signature: `external fun unsubscribeDocumentChangesJni()`
9464///
9465/// Signals the background subscription task to exit on its next iteration.
9466/// The listener GlobalRef is dropped by the task on exit (not here) to avoid
9467/// a race between unsubscribe and an in-flight dispatch.
9468#[cfg(feature = "sync")]
9469#[no_mangle]
9470pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_unsubscribeDocumentChangesJni(
9471    _env: JNIEnv,
9472    _class: JClass,
9473) {
9474    use std::sync::atomic::Ordering;
9475    DOCUMENT_SUBSCRIPTION_ACTIVE.store(false, Ordering::SeqCst);
9476    #[cfg(target_os = "android")]
9477    android_log("unsubscribeDocumentChangesJni: subscription marked inactive");
9478}
9479
9480/// Snapshot the listener `GlobalRef` from a static slot under its mutex,
9481/// returning a clone that the caller can use without holding the lock.
9482///
9483/// Pulling the lock-acquire/clone/drop dance into a helper keeps every
9484/// dispatch helper above honest about not holding a listener lock across a
9485/// re-entrant JNI `call_method` (QA #808 IDIOM).
9486#[cfg(feature = "sync")]
9487fn clone_listener(slot: &Mutex<Option<GlobalRef>>) -> Option<GlobalRef> {
9488    slot.lock().ok()?.as_ref().cloned()
9489}
9490
9491/// Reconstruct a process-global `JavaVM` from `JAVA_VM` without holding the
9492/// mutex past the read. The underlying pointer is stable for the JVM
9493/// lifetime, so dropping the lock and re-wrapping is safe — and it lets
9494/// JNI calls in dispatch helpers proceed without serializing on `JAVA_VM`.
9495#[cfg(feature = "sync")]
9496fn clone_java_vm() -> Option<jni::JavaVM> {
9497    let raw_ptr = {
9498        let guard = JAVA_VM.lock().ok()?;
9499        guard.as_ref()?.get_java_vm_pointer()
9500    };
9501    // SAFETY: JNI_OnLoad seeded JAVA_VM via `JavaVM::from_raw`, so the
9502    // pointer points at a live `sys::JavaVM` for the rest of the process.
9503    // `JavaVM` has no `Drop` impl — wrapping the same pointer twice does
9504    // not double-free.
9505    unsafe { jni::JavaVM::from_raw(raw_ptr) }.ok()
9506}
9507
9508/// Dispatch a document-change event to the registered Kotlin listener.
9509/// Attaches the current tokio worker thread to the JVM if needed.
9510#[cfg(feature = "sync")]
9511fn dispatch_document_change(collection: &str, doc_id: &str) {
9512    // Snapshot the listener and JavaVM pointer under their locks, then drop
9513    // the guards BEFORE the unbounded JNI `call_method` (QA #808 IDIOM).
9514    // Kotlin's `onChange` may re-enter Rust JNI; holding either lock across
9515    // the call would deadlock the listener slot (re-entrant lock) or
9516    // serialize every translator's dispatch through a single JVM call.
9517    // GlobalRef is Arc-shaped so cloning is cheap; JavaVM is process-stable
9518    // so reconstructing from the raw pointer is sound.
9519    let Some(listener) = clone_listener(&DOCUMENT_CHANGE_LISTENER) else {
9520        return;
9521    };
9522    let Some(java_vm) = clone_java_vm() else {
9523        return;
9524    };
9525
9526    let mut env = match java_vm.attach_current_thread() {
9527        Ok(e) => e,
9528        Err(e) => {
9529            #[cfg(target_os = "android")]
9530            android_log(&format!("dispatch_document_change: attach failed: {:?}", e));
9531            let _ = e;
9532            return;
9533        }
9534    };
9535
9536    let collection_jstr = match env.new_string(collection) {
9537        Ok(s) => s,
9538        Err(_) => return,
9539    };
9540    let doc_id_jstr = match env.new_string(doc_id) {
9541        Ok(s) => s,
9542        Err(_) => return,
9543    };
9544
9545    if let Err(e) = env.call_method(
9546        &listener,
9547        "onChange",
9548        "(Ljava/lang/String;Ljava/lang/String;)V",
9549        &[
9550            JValue::Object(&collection_jstr),
9551            JValue::Object(&doc_id_jstr),
9552        ],
9553    ) {
9554        #[cfg(target_os = "android")]
9555        android_log(&format!(
9556            "dispatch_document_change: call_method failed: {:?}",
9557            e
9558        ));
9559        let _ = e;
9560        let _ = env.exception_describe();
9561        let _ = env.exception_clear();
9562    }
9563}
9564
9565/// Dispatch an error message to the registered Kotlin listener.
9566#[cfg(feature = "sync")]
9567fn dispatch_document_error(message: &str) {
9568    // Snapshot then drop locks before JNI work — see dispatch_document_change.
9569    let Some(listener) = clone_listener(&DOCUMENT_CHANGE_LISTENER) else {
9570        return;
9571    };
9572    let Some(java_vm) = clone_java_vm() else {
9573        return;
9574    };
9575
9576    let mut env = match java_vm.attach_current_thread() {
9577        Ok(e) => e,
9578        Err(_) => return,
9579    };
9580
9581    let msg_jstr = match env.new_string(message) {
9582        Ok(s) => s,
9583        Err(_) => return,
9584    };
9585
9586    if let Err(e) = env.call_method(
9587        &listener,
9588        "onError",
9589        "(Ljava/lang/String;)V",
9590        &[JValue::Object(&msg_jstr)],
9591    ) {
9592        #[cfg(target_os = "android")]
9593        android_log(&format!(
9594            "dispatch_document_error: call_method failed: {:?}",
9595            e
9596        ));
9597        let _ = e;
9598        let _ = env.exception_describe();
9599        let _ = env.exception_clear();
9600    }
9601}
9602
9603// =============================================================================
9604// Outbound-frame poll API — dart:ffi / non-JNI consumers (ADR-059 Slice 1.b)
9605// =============================================================================
9606//
9607// Exposes the same BLE translator fan-out as `subscribeOutboundFramesJni` but
9608// via a queue-drain pattern instead of a foreign callback. The host calls
9609// `start_outbound_frames` once, then polls `poll_outbound_frames` at its own
9610// pace (e.g. from a Dart isolate loop), and calls `stop_outbound_frames` on
9611// teardown. Explicit stop avoids the Drop-drives-async problem that deferred
9612// the original `OutboundFrameCallback` UniFFI trait registration.
9613//
9614// The inbound direction (`ingest_inbound_frame`) accepts postcard-encoded
9615// typed BLE structs (i.e. the bytes *after* peat-btle has stripped the GATT
9616// framing and decrypted the envelope) and publishes the resulting document
9617// with `Some("ble")` origin so ADR-059 echo-suppression fires correctly.
9618
9619/// `OutboundSink` that appends encoded frames to an in-process queue instead
9620/// of dispatching to a JNI callback. Used by `start_outbound_frames`.
9621#[cfg(all(feature = "sync", feature = "bluetooth"))]
9622struct QueueOutboundSink {
9623    transport_id: &'static str,
9624    queue: Arc<std::sync::Mutex<std::collections::VecDeque<OutboundFrame>>>,
9625}
9626
9627#[cfg(all(feature = "sync", feature = "bluetooth"))]
9628#[async_trait::async_trait]
9629impl peat_mesh::transport::OutboundSink for QueueOutboundSink {
9630    async fn send_outbound(
9631        &self,
9632        bytes: Vec<u8>,
9633        ctx: &peat_mesh::transport::TranslationContext,
9634    ) -> anyhow::Result<()> {
9635        let collection = ctx.collection.clone().unwrap_or_default();
9636        self.queue
9637            .lock()
9638            .map_err(|e| anyhow::anyhow!("outbound_queue poisoned: {e}"))?
9639            .push_back(OutboundFrame {
9640                transport_id: self.transport_id.to_string(),
9641                collection,
9642                bytes,
9643            });
9644        Ok(())
9645    }
9646}
9647
9648/// Internal helper: registers the ble (and optionally ble-lite) translator +
9649/// sink pair with `TransportManager`, starts the fan-out, and returns the
9650/// `FanoutHandle`. On any failure, already-registered translators are rolled
9651/// back before the error propagates.
9652///
9653/// `sink_factory` is a closure that receives the `transport_id` string and
9654/// returns the `Arc<dyn OutboundSink>` to wire for that transport. Called
9655/// once for `"ble"` and, with `lite-bridge` on, once for `"ble-lite"`.
9656#[cfg(all(feature = "sync", feature = "bluetooth"))]
9657impl PeatNode {
9658    fn register_ble_fanout(
9659        &self,
9660        sink_factory: impl Fn(&'static str) -> Arc<dyn peat_mesh::transport::OutboundSink>,
9661    ) -> anyhow::Result<peat_mesh::transport::FanoutHandle> {
9662        let translator_dyn: Arc<dyn peat_mesh::transport::Translator> = self.ble_translator.clone();
9663        let ble_sink = sink_factory("ble");
9664
9665        let collections = vec![
9666            self.ble_translator.tracks_collection().to_string(),
9667            self.ble_translator.nodes_collection().to_string(),
9668            self.ble_translator.alerts_collection().to_string(),
9669            self.ble_translator.canned_messages_collection().to_string(),
9670        ];
9671
9672        #[cfg(feature = "lite-bridge")]
9673        let lite_bridge_translator_id = peat_mesh::transport::BLE_LITE_BRIDGE;
9674        #[cfg(feature = "lite-bridge")]
9675        let mut collections = collections;
9676        #[cfg(feature = "lite-bridge")]
9677        for c in LITE_BRIDGE_COLLECTIONS {
9678            // Dedup: `nodes` is already in the base list above. Pushing it again
9679            // would spawn a SECOND observer task for the same collection, and the
9680            // two race on the single-pop `pending_origins` map — one pops the
9681            // ble-lite origin (skips), the other pops `None` and re-fans the
9682            // ingested doc back out → the roster fan-out storm. One observer per
9683            // collection keeps ADR-059 echo-suppression intact.
9684            if !collections.iter().any(|existing| existing == c) {
9685                collections.push((*c).to_string());
9686            }
9687        }
9688        let collections = collections;
9689
9690        self.runtime.block_on(async {
9691            self.transport_manager
9692                .register_translator(
9693                    translator_dyn,
9694                    ble_sink,
9695                    peat_mesh::transport::TranslatorRegistrationConfig::ble(),
9696                )
9697                .await?;
9698
9699            #[cfg(feature = "lite-bridge")]
9700            {
9701                let lite_translator: Arc<dyn peat_mesh::transport::Translator> = Arc::new(
9702                    CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS),
9703                );
9704                let lite_sink = sink_factory(lite_bridge_translator_id);
9705                if let Err(e) = self
9706                    .transport_manager
9707                    .register_translator(
9708                        lite_translator,
9709                        lite_sink,
9710                        peat_mesh::transport::TranslatorRegistrationConfig::ble(),
9711                    )
9712                    .await
9713                {
9714                    let _ = self.transport_manager.unregister_translator("ble").await;
9715                    return Err(e);
9716                }
9717            }
9718
9719            match self
9720                .transport_manager
9721                .start_fanout(Arc::clone(&self.node), collections)
9722            {
9723                Ok(handle) => Ok(handle),
9724                Err(e) => {
9725                    #[cfg(feature = "lite-bridge")]
9726                    {
9727                        let _ = self
9728                            .transport_manager
9729                            .unregister_translator(lite_bridge_translator_id)
9730                            .await;
9731                    }
9732                    let _ = self.transport_manager.unregister_translator("ble").await;
9733                    Err(e)
9734                }
9735            }
9736        })
9737    }
9738
9739    /// Re-emit a freshly-ingested BLE frame onto the outbound queue so this
9740    /// node RELAYS it to its other BLE peers — multi-hop A->B->C.
9741    /// peat-mesh's fan-out already re-fans an ingested frame to OTHER
9742    /// transports (BLE->Wi-Fi/iroh) but suppresses same-transport BLE->BLE
9743    /// re-emit to avoid a broadcast loop; that suppression is exactly what
9744    /// strands an all-BLE follower. Re-emitting here closes that hop.
9745    /// Deduped by frame content with a short TTL so a relayed frame isn't
9746    /// re-broadcast in a loop: a NEW value (different bytes)
9747    /// relays immediately, while identical re-advertises inside the TTL window
9748    /// are dropped (this is what keeps it from recreating the storm the
9749    /// suppression was guarding against). No-op unless an outbound subscription
9750    /// is actively draining the queue, so an idle node doesn't grow it.
9751    fn relay_ble_frame(&self, transport_id: &str, collection: &str, bytes: &[u8]) {
9752        use std::collections::hash_map::DefaultHasher;
9753        use std::hash::{Hash, Hasher};
9754        use std::time::{Duration, Instant};
9755
9756        const RELAY_DEDUP_TTL: Duration = Duration::from_secs(5);
9757        const RELAY_SEEN_CAP: usize = 2048;
9758
9759        // Do NOT relay presence ("nodes"): its heartbeat timestamp changes every
9760        // beat, so every frame is unique and escapes the content dedup — relaying
9761        // it ~Nx-amplifies BLE traffic (congestion → missed heartbeats → roster
9762        // liveness flapping) AND re-broadcasts stale node-identity docs from
9763        // peers' stores (resurfacing zombie identities, so the roster flips
9764        // between a node's old id and its callsign). Presence reaches direct
9765        // neighbours via each node's own advertise; only app STATE needs
9766        // multi-hop relay (counter "demo", "cells", "mission", "commands",
9767        // "markers"), and those re-advertise identical bytes so the dedup
9768        // throttles them to one relay per change.
9769        if collection == "nodes" {
9770            return;
9771        }
9772
9773        let active = match self.outbound_fanout.lock() {
9774            Ok(g) => g.is_some(),
9775            Err(e) => e.into_inner().is_some(),
9776        };
9777        if !active {
9778            return;
9779        }
9780
9781        let mut h = DefaultHasher::new();
9782        transport_id.hash(&mut h);
9783        collection.hash(&mut h);
9784        bytes.hash(&mut h);
9785        let key = h.finish();
9786
9787        let now = Instant::now();
9788        {
9789            let mut seen = self.relay_seen.lock().unwrap_or_else(|e| e.into_inner());
9790            seen.retain(|_, t| now.duration_since(*t) < RELAY_DEDUP_TTL);
9791            if seen.contains_key(&key) {
9792                return; // identical frame relayed recently — drop to break
9793                        // loops
9794            }
9795            if seen.len() >= RELAY_SEEN_CAP {
9796                seen.clear();
9797            }
9798            seen.insert(key, now);
9799        }
9800
9801        self.outbound_queue
9802            .lock()
9803            .unwrap_or_else(|e| e.into_inner())
9804            .push_back(OutboundFrame {
9805                transport_id: transport_id.to_string(),
9806                collection: collection.to_string(),
9807                bytes: bytes.to_vec(),
9808            });
9809    }
9810}
9811
9812#[cfg(all(feature = "sync", feature = "bluetooth"))]
9813#[uniffi::export]
9814impl PeatNode {
9815    /// Subscribe to outbound BLE frames via a poll queue.
9816    ///
9817    /// After calling this, encoded frames produced by the `BleTranslator`
9818    /// fan-out accumulate in an internal unbounded queue. Call
9819    /// [`poll_outbound_frames`] frequently to drain it — if the consumer
9820    /// pauses polling the queue will grow without bound, one `Vec<u8>`
9821    /// payload per BLE frame.
9822    ///
9823    /// Idempotent — a second call while already subscribed is a no-op
9824    /// (returns `Ok`).
9825    ///
9826    /// Call [`stop_outbound_frames`] to unsubscribe, tear down the fan-out,
9827    /// and clear any residual frames from the queue.
9828    pub fn start_outbound_frames(&self) -> Result<(), PeatError> {
9829        {
9830            let guard = self
9831                .outbound_fanout
9832                .lock()
9833                .map_err(|_| PeatError::SyncError {
9834                    msg: "outbound_fanout poisoned".to_string(),
9835                })?;
9836            if guard.is_some() {
9837                return Ok(()); // already running
9838            }
9839        }
9840        let queue = Arc::clone(&self.outbound_queue);
9841        let handle = self
9842            .register_ble_fanout(move |tid| {
9843                Arc::new(QueueOutboundSink {
9844                    transport_id: tid,
9845                    queue: Arc::clone(&queue),
9846                })
9847            })
9848            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
9849        *self
9850            .outbound_fanout
9851            .lock()
9852            .map_err(|_| PeatError::SyncError {
9853                msg: "outbound_fanout poisoned".to_string(),
9854            })? = Some(handle);
9855        Ok(())
9856    }
9857
9858    /// Drain all queued outbound frames produced since the last call.
9859    ///
9860    /// Returns an empty `Vec` when no frames are pending or when
9861    /// [`start_outbound_frames`] has not been called. Non-blocking.
9862    pub fn poll_outbound_frames(&self) -> Vec<OutboundFrame> {
9863        // If the Mutex is poisoned (a thread panicked while holding it) we
9864        // recover the inner value rather than propagating a panic — the
9865        // VecDeque state is consistent enough to drain safely.
9866        let mut q = self
9867            .outbound_queue
9868            .lock()
9869            .unwrap_or_else(|e| e.into_inner());
9870        q.drain(..).collect()
9871    }
9872
9873    /// Stop outbound-frame delivery and tear down the BLE fan-out.
9874    ///
9875    /// Drops the `FanoutHandle` (cancels observer tasks), unregisters the BLE
9876    /// translator(s), and clears the outbound queue so that stale frames are
9877    /// not delivered after a subsequent [`start_outbound_frames`].
9878    ///
9879    /// Idempotent — safe to call when not subscribed.
9880    pub fn stop_outbound_frames(&self) {
9881        let handle = self
9882            .outbound_fanout
9883            .lock()
9884            .unwrap_or_else(|e| e.into_inner())
9885            .take();
9886        drop(handle); // cancels fan-out observer tasks
9887
9888        // Clear residual frames so a subsequent start_outbound_frames sees a
9889        // clean queue rather than frames from the previous subscription window.
9890        self.outbound_queue
9891            .lock()
9892            .unwrap_or_else(|e| e.into_inner())
9893            .clear();
9894
9895        // Unregister the translator(s) so a future start_outbound_frames
9896        // can re-register without hitting the duplicate-id rejection.
9897        self.runtime.block_on(async {
9898            #[cfg(feature = "lite-bridge")]
9899            {
9900                let _ = self
9901                    .transport_manager
9902                    .unregister_translator(peat_mesh::transport::BLE_LITE_BRIDGE)
9903                    .await;
9904            }
9905            let _ = self.transport_manager.unregister_translator("ble").await;
9906        });
9907    }
9908
9909    /// Feed a BLE inbound frame into the mesh.
9910    ///
9911    /// `postcard_bytes` must be the postcard-encoded typed BLE struct
9912    /// produced by `peat-btle` *after* it has stripped the GATT framing and
9913    /// decrypted the envelope (i.e. the bytes `peat-btle` would pass to its
9914    /// internal `Translator::decode_inbound`).
9915    ///
9916    /// `collection` must name the document collection the bytes belong to
9917    /// (e.g. `"tracks"`, `"platforms"`) — peat-btle knows this from the GATT
9918    /// characteristic or frame type and should pass it through unchanged.
9919    ///
9920    /// On success returns the newly-published document ID. Returns `Ok(None)`
9921    /// if the bytes are addressed to an unknown collection (graceful decline).
9922    pub fn ingest_inbound_frame(
9923        &self,
9924        collection: String,
9925        postcard_bytes: Vec<u8>,
9926    ) -> Result<Option<String>, PeatError> {
9927        use peat_mesh::transport::{TranslationContext, Translator};
9928        let ctx = TranslationContext::inbound("ble").with_collection(collection);
9929        let doc = self
9930            .runtime
9931            .block_on(self.ble_translator.decode_inbound(&postcard_bytes, &ctx))
9932            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
9933        let Some(mesh_doc) = doc else {
9934            return Ok(None);
9935        };
9936        let collection_name = ctx.collection.unwrap_or_default();
9937        let id = self
9938            .runtime
9939            .block_on(self.node.publish_with_origin(
9940                &collection_name,
9941                mesh_doc,
9942                Some("ble".to_string()),
9943            ))
9944            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
9945        // Multi-hop: relay this frame to our other BLE peers (deduped).
9946        self.relay_ble_frame("ble", &collection_name, &postcard_bytes);
9947        Ok(Some(id.to_string()))
9948    }
9949
9950    /// Ingest an inbound BLE frame that arrived on the universal-Document
9951    /// (peat-lite / `ble-lite`) codec, as opposed to the typed 0xB6 path in
9952    /// [`ingest_inbound_frame`]. Decodes via the `CollectionGatedLiteBridge`
9953    /// and republishes with `Some("ble-lite")` origin so the mesh re-fans it
9954    /// to the other transports without looping back to BLE. Used for raw
9955    /// collections (e.g. the `demo` counter) that the typed translator
9956    /// declines.
9957    #[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
9958    pub fn ingest_inbound_lite_frame(
9959        &self,
9960        collection: String,
9961        envelope_bytes: Vec<u8>,
9962    ) -> Result<Option<String>, PeatError> {
9963        use peat_mesh::transport::{TranslationContext, Translator, BLE_LITE_BRIDGE};
9964        let bridge = CollectionGatedLiteBridge::for_ble_with_collections(LITE_BRIDGE_COLLECTIONS);
9965        let ctx = TranslationContext::inbound(BLE_LITE_BRIDGE).with_collection(collection);
9966        let doc = self
9967            .runtime
9968            .block_on(bridge.decode_inbound(&envelope_bytes, &ctx))
9969            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
9970        let Some(mesh_doc) = doc else {
9971            return Ok(None);
9972        };
9973        let collection_name = ctx.collection.unwrap_or_default();
9974        let id = self
9975            .runtime
9976            .block_on(self.node.publish_with_origin(
9977                &collection_name,
9978                mesh_doc,
9979                Some(BLE_LITE_BRIDGE.to_string()),
9980            ))
9981            .map_err(|e| PeatError::SyncError { msg: e.to_string() })?;
9982        // Multi-hop: relay this lite frame to our other BLE peers (deduped).
9983        self.relay_ble_frame(BLE_LITE_BRIDGE, &collection_name, &envelope_bytes);
9984        Ok(Some(id.to_string()))
9985    }
9986
9987    /// Publish a JSON document through the **node layer** — the same path the
9988    /// Android `publishDocumentJni` uses — so the write reaches the ADR-059
9989    /// fan-out and is emitted over the bridged transports (BLE/Wi-Fi). The
9990    /// `id` field in the JSON, when present, becomes the document id
9991    /// (returned).
9992    ///
9993    /// Use this instead of `put_document` when the write must propagate to
9994    /// peers via the bridged radios: `put_document`/`put_node` write straight
9995    /// to `storage_backend`, which the fan-out does not observe, so those never
9996    /// emit a BLE frame. Needed by the iOS bridge (which drives the poll API
9997    /// from Dart and has no JNI `publishDocumentJni`).
9998    #[cfg(feature = "sync")]
9999    pub fn publish_document(&self, collection: String, json: String) -> Result<String, PeatError> {
10000        self.runtime
10001            .block_on(publish_document_into_node(&self.node, &collection, &json))
10002            .map_err(|e| PeatError::SyncError { msg: e.to_string() })
10003    }
10004}
10005
10006// =============================================================================
10007// OutboundFrameCallback JNI (ADR-059 Slice 1.b)
10008// =============================================================================
10009//
10010// Bridges `TransportManager`'s per-transport fan-out (peat-mesh) into a
10011// Kotlin callback so a consumer plugin's BLE manager can deliver encoded
10012// frames over the radio. The JNI shape mirrors `subscribeDocumentChangesJni`
10013// — a single GlobalRef in a static slot, replaceable on re-subscribe — so
10014// the same patterns audited on PR #803 carry over.
10015
10016/// `OutboundSink` implementation that forwards encoded bytes into the
10017/// registered Kotlin listener. One instance is registered with
10018/// `TransportManager` per `transport_id` we want to fan out — currently
10019/// `"ble"` for typed 0xB6 frames and (with `lite-bridge` on) `"ble-lite"`
10020/// for universal Document envelopes. The structure generalizes to
10021/// LoRa/SBD/etc.
10022#[cfg(all(feature = "sync", feature = "bluetooth"))]
10023struct JniOutboundSink {
10024    transport_id: &'static str,
10025}
10026
10027/// `Translator` wrapper that gates `encode_outbound` by collection.
10028/// Wraps a [`peat_mesh::transport::LiteBridgeTranslator`] (catch-all
10029/// codec — encodes any collection it's handed) with a peat-ffi-policy
10030/// allow-list, so the universal-Document fan-out only fires for
10031/// collections explicitly opted in.
10032///
10033/// Without this wrapper, registering both the typed `BleTranslator`
10034/// (which encodes `"tracks"`/`"nodes"`/`"alerts"`/`"canned_messages"`
10035/// to compact 0xB6 frames) AND the catch-all `LiteBridgeTranslator` on
10036/// the same `TransportManager` would cause **double emission** for the
10037/// typed collections — both translators would encode the same doc and
10038/// dispatch separate frames to Kotlin. The plugin would receive
10039/// duplicate copies, and BLE-link bandwidth doubles for no gain. The
10040/// gate stays in peat-ffi (the consumer that owns the policy decision)
10041/// rather than in `LiteBridgeTranslator` itself, matching ADR-059's
10042/// "policy lives at the consumer, codec is generic" direction.
10043///
10044/// Slice 2's per-doc `allowed_transports` will eventually replace this
10045/// with a runtime annotation on each Document; until then, the
10046/// peat-ffi-static allow-list is the right shape.
10047#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10048struct CollectionGatedLiteBridge {
10049    inner: peat_mesh::transport::LiteBridgeTranslator,
10050    allowed: std::collections::HashSet<&'static str>,
10051}
10052
10053#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10054impl CollectionGatedLiteBridge {
10055    fn for_ble_with_collections(collections: &'static [&'static str]) -> Self {
10056        Self {
10057            inner: peat_mesh::transport::LiteBridgeTranslator::for_ble(),
10058            allowed: collections.iter().copied().collect(),
10059        }
10060    }
10061}
10062
10063#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10064#[async_trait::async_trait]
10065impl peat_mesh::transport::Translator for CollectionGatedLiteBridge {
10066    fn transport_id(&self) -> &'static str {
10067        self.inner.transport_id()
10068    }
10069
10070    async fn encode_outbound(
10071        &self,
10072        doc: &peat_mesh::sync::types::Document,
10073        ctx: &peat_mesh::transport::TranslationContext,
10074    ) -> Option<Vec<u8>> {
10075        // Decline silently for collections outside the allow-list.
10076        // This is the policy filter, not a codec error — matches the
10077        // BleTranslator decline behaviour for unknown collections.
10078        let collection = ctx.collection.as_deref()?;
10079        if !self.allowed.contains(collection) {
10080            return None;
10081        }
10082        self.inner.encode_outbound(doc, ctx).await
10083    }
10084
10085    async fn decode_inbound(
10086        &self,
10087        bytes: &[u8],
10088        ctx: &peat_mesh::transport::TranslationContext,
10089    ) -> anyhow::Result<Option<peat_mesh::sync::types::Document>> {
10090        // Inbound is collection-agnostic at this codec level (the
10091        // envelope carries the collection). The receive-side policy
10092        // decision (which collections to publish_with_origin) lives
10093        // in the consumer (plugin Kotlin), so the gate doesn't apply
10094        // here.
10095        self.inner.decode_inbound(bytes, ctx).await
10096    }
10097}
10098
10099/// Universal-Document collections that ride the `"ble-lite"` codec
10100/// instead of the typed 0xB6 path. Add new entries here when a new
10101/// collection joins the universal transport (chats, alerts-v2, etc.).
10102/// Keep the list tight — every entry is one more codec the universal
10103/// path encodes for, and double-emission with the typed BleTranslator
10104/// would result if both lists overlap.
10105// `nodes` (capabilities/roster) rides the universal codec — the typed
10106// BleTranslator declines it (it only encodes tracks/platforms/alerts/
10107// canned_messages), so without this entry capabilities never reach a BLE
10108// frame and remote rosters stay empty. Safe to carry here now that
10109// `put_node` publishes through the node layer (same wrapped representation
10110// as the ingest), so the two sides converge instead of re-syncing forever.
10111#[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10112const LITE_BRIDGE_COLLECTIONS: &[&str] =
10113    &["markers", "demo", "nodes", "mission", "cells", "commands"];
10114
10115#[cfg(all(feature = "sync", feature = "bluetooth"))]
10116#[async_trait::async_trait]
10117impl peat_mesh::transport::OutboundSink for JniOutboundSink {
10118    async fn send_outbound(
10119        &self,
10120        bytes: Vec<u8>,
10121        ctx: &peat_mesh::transport::TranslationContext,
10122    ) -> anyhow::Result<()> {
10123        let collection = ctx.collection.as_deref().unwrap_or("");
10124        dispatch_outbound_frame(self.transport_id, collection, &bytes);
10125        Ok(())
10126    }
10127}
10128
10129/// JNI: Subscribe to outbound BLE-encoded frames produced by the
10130/// `BleTranslator` in `TransportManager`'s fan-out.
10131///
10132/// Kotlin signature:
10133/// `external fun subscribeOutboundFramesJni(handle: Long, listener:
10134/// OutboundFrameListener): Boolean`
10135///
10136/// The listener receives `onFrame(transportId, collection, bytes)` for
10137/// each encoded document the translator produces. Calls fire on the
10138/// tokio runtime thread; the listener must tolerate any-thread invocation
10139/// (the plugin posts to its own executor before touching radio state).
10140///
10141/// **Idempotent**: a second call replaces the listener `GlobalRef`; the
10142/// underlying translator + sink registration and observer fan-out tasks
10143/// are kept alive across the swap so no frames are lost between the two
10144/// listeners. Use `unsubscribeOutboundFramesJni` to fully tear down.
10145#[cfg(all(feature = "sync", feature = "bluetooth"))]
10146#[no_mangle]
10147pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_subscribeOutboundFramesJni(
10148    mut env: JNIEnv,
10149    _class: JClass,
10150    handle: i64,
10151    listener: jni::objects::JObject,
10152) -> jboolean {
10153    if handle == 0 {
10154        #[cfg(target_os = "android")]
10155        android_log("subscribeOutboundFramesJni: Invalid handle (0)");
10156        return 0;
10157    }
10158
10159    let listener_global = match env.new_global_ref(&listener) {
10160        Ok(g) => g,
10161        Err(e) => {
10162            #[cfg(target_os = "android")]
10163            android_log(&format!(
10164                "subscribeOutboundFramesJni: new_global_ref failed: {:?}",
10165                e
10166            ));
10167            let _ = e;
10168            return 0;
10169        }
10170    };
10171
10172    // Listener swap is unconditional — second-subscribe just rebinds.
10173    *OUTBOUND_FRAME_LISTENER.lock().unwrap() = Some(listener_global);
10174
10175    // If a fan-out is already running, the swap above is sufficient — the
10176    // existing JniOutboundSink reads the listener slot dynamically.
10177    {
10178        let handle_slot = OUTBOUND_FRAME_FANOUT.lock().unwrap();
10179        if handle_slot.is_some() {
10180            return 1;
10181        }
10182    }
10183
10184    // First subscribe: register translator + sink and start fan-out.
10185    // `TransportManager` is not Clone, so we hold the `node_owner` Arc by
10186    // borrow (not by taking ownership) for the duration of the call;
10187    // forget happens after the registration block completes.
10188    let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
10189
10190    // Delegate to the shared registration helper so the JNI and the
10191    // poll-API paths stay aligned. The factory produces a `JniOutboundSink`
10192    // whose `send_outbound` dispatches to the registered Kotlin GlobalRef.
10193    let final_result =
10194        node_owner.register_ble_fanout(|tid| Arc::new(JniOutboundSink { transport_id: tid }));
10195
10196    std::mem::forget(node_owner);
10197
10198    match final_result {
10199        Ok(fanout_handle) => {
10200            *OUTBOUND_FRAME_FANOUT.lock().unwrap() = Some(fanout_handle);
10201            1
10202        }
10203        Err(_e) => {
10204            // Roll back the listener stash so a future retry isn't observed
10205            // as "already subscribed".
10206            *OUTBOUND_FRAME_LISTENER.lock().unwrap() = None;
10207            #[cfg(target_os = "android")]
10208            android_log(&format!(
10209                "subscribeOutboundFramesJni: register/start_fanout failed: {}",
10210                _e
10211            ));
10212            0
10213        }
10214    }
10215}
10216
10217/// JNI: Unsubscribe from outbound frame delivery.
10218///
10219/// Kotlin signature: `external fun unsubscribeOutboundFramesJni(handle: Long)`
10220///
10221/// Drops the `FanoutHandle` (cancelling observer tasks), unregisters the
10222/// translator, and clears the listener `GlobalRef`. Idempotent — calling
10223/// twice or before any subscribe is a no-op.
10224#[cfg(all(feature = "sync", feature = "bluetooth"))]
10225#[no_mangle]
10226pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_unsubscribeOutboundFramesJni(
10227    _env: JNIEnv,
10228    _class: JClass,
10229    handle: i64,
10230) {
10231    // Drop the FanoutHandle first so no further frames are fanned out
10232    // while we're tearing down.
10233    let _ = OUTBOUND_FRAME_FANOUT.lock().unwrap().take();
10234
10235    if handle != 0 {
10236        let node_owner = unsafe { Arc::from_raw(handle as *const PeatNode) };
10237        node_owner.runtime.block_on(async {
10238            // Unregister both translators that the lite-bridge build
10239            // registered (ble + ble-lite). Each call independently
10240            // rejects "translator not registered", so the order doesn't
10241            // matter and a missing entry on either side is benign.
10242            #[cfg(feature = "lite-bridge")]
10243            {
10244                let _ = node_owner
10245                    .transport_manager
10246                    .unregister_translator(peat_mesh::transport::BLE_LITE_BRIDGE)
10247                    .await;
10248            }
10249            let _ = node_owner
10250                .transport_manager
10251                .unregister_translator("ble")
10252                .await;
10253        });
10254        std::mem::forget(node_owner);
10255    }
10256
10257    *OUTBOUND_FRAME_LISTENER.lock().unwrap() = None;
10258
10259    #[cfg(target_os = "android")]
10260    android_log("unsubscribeOutboundFramesJni: subscription torn down");
10261}
10262
10263/// Dispatch an outbound frame to the registered Kotlin listener.
10264/// Attaches the current tokio worker thread to the JVM if needed.
10265#[cfg(all(feature = "sync", feature = "bluetooth"))]
10266fn dispatch_outbound_frame(transport_id: &str, collection: &str, bytes: &[u8]) {
10267    // Snapshot then drop locks before JNI work — see dispatch_document_change.
10268    let Some(listener) = clone_listener(&OUTBOUND_FRAME_LISTENER) else {
10269        return;
10270    };
10271    let Some(java_vm) = clone_java_vm() else {
10272        return;
10273    };
10274
10275    let mut env = match java_vm.attach_current_thread() {
10276        Ok(e) => e,
10277        Err(e) => {
10278            #[cfg(target_os = "android")]
10279            android_log(&format!("dispatch_outbound_frame: attach failed: {:?}", e));
10280            let _ = e;
10281            return;
10282        }
10283    };
10284
10285    let transport_jstr = match env.new_string(transport_id) {
10286        Ok(s) => s,
10287        Err(_) => return,
10288    };
10289    let collection_jstr = match env.new_string(collection) {
10290        Ok(s) => s,
10291        Err(_) => return,
10292    };
10293    let bytes_jarr = match env.byte_array_from_slice(bytes) {
10294        Ok(a) => a,
10295        Err(e) => {
10296            #[cfg(target_os = "android")]
10297            android_log(&format!(
10298                "dispatch_outbound_frame: byte_array_from_slice failed: {:?}",
10299                e
10300            ));
10301            let _ = e;
10302            return;
10303        }
10304    };
10305
10306    if let Err(e) = env.call_method(
10307        &listener,
10308        "onFrame",
10309        "(Ljava/lang/String;Ljava/lang/String;[B)V",
10310        &[
10311            JValue::Object(&transport_jstr),
10312            JValue::Object(&collection_jstr),
10313            JValue::Object(&bytes_jarr),
10314        ],
10315    ) {
10316        #[cfg(target_os = "android")]
10317        android_log(&format!(
10318            "dispatch_outbound_frame: call_method failed: {:?}",
10319            e
10320        ));
10321        let _ = e;
10322        let _ = env.exception_describe();
10323        let _ = env.exception_clear();
10324    }
10325}
10326
10327// =============================================================================
10328// Blob Transfer JNI (ADR-060)
10329// =============================================================================
10330
10331/// JNI: Enable blob transfer on the PeatNode.
10332///
10333/// Kotlin signature:
10334/// `external fun enableBlobTransferJni(handle: Long, bindAddr: String?):
10335/// Boolean`
10336#[cfg(feature = "sync")]
10337#[no_mangle]
10338pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_enableBlobTransferJni(
10339    mut env: JNIEnv,
10340    _class: JClass,
10341    handle: i64,
10342    bind_addr: JString,
10343) -> jboolean {
10344    if handle == 0 {
10345        return 0;
10346    }
10347    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10348
10349    let addr_str: Option<String> = if bind_addr.is_null() {
10350        None
10351    } else {
10352        env.get_string(&bind_addr).ok().map(|s| s.into())
10353    };
10354    let bind: Option<std::net::SocketAddr> =
10355        addr_str.and_then(|s| if s.is_empty() { None } else { s.parse().ok() });
10356
10357    let result = match node.enable_blob_transfer(bind) {
10358        Ok(()) => 1,
10359        Err(e) => {
10360            #[cfg(target_os = "android")]
10361            android_log(&format!("enableBlobTransferJni: {}", e));
10362            0
10363        }
10364    };
10365    std::mem::forget(node);
10366    result
10367}
10368
10369/// JNI: Add a known blob peer.
10370///
10371/// Kotlin signature:
10372/// `external fun blobAddPeerJni(handle: Long, peerIdHex: String, address:
10373/// String): Boolean`
10374#[cfg(feature = "sync")]
10375#[no_mangle]
10376pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobAddPeerJni(
10377    mut env: JNIEnv,
10378    _class: JClass,
10379    handle: i64,
10380    peer_id_hex: JString,
10381    address: JString,
10382) -> jboolean {
10383    if handle == 0 {
10384        return 0;
10385    }
10386    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10387
10388    let peer_hex: String = match env.get_string(&peer_id_hex) {
10389        Ok(s) => s.into(),
10390        Err(_) => {
10391            std::mem::forget(node);
10392            return 0;
10393        }
10394    };
10395    let addr: String = match env.get_string(&address) {
10396        Ok(s) => s.into(),
10397        Err(_) => {
10398            std::mem::forget(node);
10399            return 0;
10400        }
10401    };
10402
10403    let result = match node.blob_add_peer(&peer_hex, &addr) {
10404        Ok(()) => 1,
10405        Err(e) => {
10406            #[cfg(target_os = "android")]
10407            android_log(&format!("blobAddPeerJni: {}", e));
10408            0
10409        }
10410    };
10411    std::mem::forget(node);
10412    result
10413}
10414
10415/// JNI: Store bytes as a blob. Returns the content hash as a hex string.
10416///
10417/// Kotlin signature:
10418/// `external fun blobPutJni(handle: Long, data: ByteArray, contentType:
10419/// String): String?`
10420#[cfg(feature = "sync")]
10421#[no_mangle]
10422pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobPutJni(
10423    mut env: JNIEnv,
10424    _class: JClass,
10425    handle: i64,
10426    data: jni::objects::JByteArray,
10427    content_type: JString,
10428) -> jstring {
10429    if handle == 0 {
10430        return std::ptr::null_mut();
10431    }
10432    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10433
10434    let bytes = match env.convert_byte_array(&data) {
10435        Ok(b) => b,
10436        Err(_) => {
10437            std::mem::forget(node);
10438            return std::ptr::null_mut();
10439        }
10440    };
10441    let ct: String = match env.get_string(&content_type) {
10442        Ok(s) => s.into(),
10443        Err(_) => {
10444            std::mem::forget(node);
10445            return std::ptr::null_mut();
10446        }
10447    };
10448
10449    let result = match node.blob_put(&bytes, &ct) {
10450        Ok(hash) => env.new_string(&hash).ok().map(|s| s.into_raw()),
10451        Err(e) => {
10452            #[cfg(target_os = "android")]
10453            android_log(&format!("blobPutJni: {}", e));
10454            None
10455        }
10456    };
10457    std::mem::forget(node);
10458    result.unwrap_or(std::ptr::null_mut())
10459}
10460
10461/// JNI: Fetch blob bytes by hash. Returns byte[] or null.
10462///
10463/// Kotlin signature:
10464/// `external fun blobGetJni(handle: Long, hashHex: String): ByteArray?`
10465#[cfg(feature = "sync")]
10466#[no_mangle]
10467pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobGetJni(
10468    mut env: JNIEnv,
10469    _class: JClass,
10470    handle: i64,
10471    hash_hex: JString,
10472) -> jni::objects::JByteArray<'static> {
10473    if handle == 0 {
10474        return JByteArray::default();
10475    }
10476    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10477
10478    let hash: String = match env.get_string(&hash_hex) {
10479        Ok(s) => s.into(),
10480        Err(_) => {
10481            std::mem::forget(node);
10482            return JByteArray::default();
10483        }
10484    };
10485
10486    let result = match node.blob_get(&hash) {
10487        Ok(bytes) => env.byte_array_from_slice(&bytes).ok(),
10488        Err(e) => {
10489            #[cfg(target_os = "android")]
10490            android_log(&format!("blobGetJni: {}", e));
10491            None
10492        }
10493    };
10494    std::mem::forget(node);
10495    // Safety: JByteArray has no lifetime on the default — transmute is needed
10496    // because the JNI return type doesn't carry a lifetime parameter.
10497    result
10498        .map(|arr| unsafe { std::mem::transmute(arr) })
10499        .unwrap_or(JByteArray::default())
10500}
10501
10502/// JNI: Check if blob exists locally.
10503///
10504/// Kotlin signature:
10505/// `external fun blobExistsLocallyJni(handle: Long, hashHex: String): Boolean`
10506#[cfg(feature = "sync")]
10507#[no_mangle]
10508pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobExistsLocallyJni(
10509    mut env: JNIEnv,
10510    _class: JClass,
10511    handle: i64,
10512    hash_hex: JString,
10513) -> jboolean {
10514    if handle == 0 {
10515        return 0;
10516    }
10517    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10518
10519    let hash: String = match env.get_string(&hash_hex) {
10520        Ok(s) => s.into(),
10521        Err(_) => {
10522            std::mem::forget(node);
10523            return 0;
10524        }
10525    };
10526
10527    let result = if node.blob_exists_locally(&hash) {
10528        1
10529    } else {
10530        0
10531    };
10532    std::mem::forget(node);
10533    result
10534}
10535
10536/// JNI: Get blob endpoint ID as hex string (or null if blob transfer disabled).
10537///
10538/// Kotlin signature:
10539/// `external fun blobEndpointIdJni(handle: Long): String?`
10540#[cfg(feature = "sync")]
10541#[no_mangle]
10542pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_blobEndpointIdJni(
10543    mut env: JNIEnv,
10544    _class: JClass,
10545    handle: i64,
10546) -> jstring {
10547    if handle == 0 {
10548        return std::ptr::null_mut();
10549    }
10550    let node = unsafe { Arc::from_raw(handle as *const PeatNode) };
10551
10552    let result = match node.blob_endpoint_id() {
10553        Some(id) => env.new_string(&id).ok().map(|s| s.into_raw()),
10554        None => None,
10555    };
10556    std::mem::forget(node);
10557    result.unwrap_or(std::ptr::null_mut())
10558}
10559
10560// =============================================================================
10561// JNI Native Method Registration
10562// =============================================================================
10563//
10564// Android's linker namespace isolation prevents normal JNI symbol lookup.
10565// We provide a nativeInit function that Kotlin must call after System.load()
10566// to explicitly register the native methods.
10567
10568/// Register native methods for PeatJni class
10569///
10570/// This must be called from Kotlin after System.load() to register native
10571/// methods. Android's classloader isolation prevents JNI_OnLoad from finding
10572/// the class.
10573///
10574/// Kotlin usage:
10575/// ```kotlin
10576/// companion object {
10577///     init {
10578///         System.load(libPath)
10579///         nativeInit()
10580///     }
10581///     @JvmStatic external fun nativeInit()
10582/// }
10583/// ```
10584#[no_mangle]
10585pub extern "system" fn Java_com_defenseunicorns_peat_PeatJni_nativeInit(
10586    mut env: JNIEnv,
10587    class: JClass,
10588) {
10589    use jni::NativeMethod;
10590
10591    let methods: Vec<NativeMethod> = vec![
10592        NativeMethod {
10593            name: "peatVersion".into(),
10594            sig: "()Ljava/lang/String;".into(),
10595            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peatVersion as *mut c_void,
10596        },
10597        NativeMethod {
10598            name: "testJni".into(),
10599            sig: "()Ljava/lang/String;".into(),
10600            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_testJni as *mut c_void,
10601        },
10602        #[cfg(target_os = "android")]
10603        NativeMethod {
10604            name: "setAndroidContextJni".into(),
10605            // (Ljava/lang/Object;)V — Kotlin `Any` lowers to java.lang.Object.
10606            sig: "(Ljava/lang/Object;)V".into(),
10607            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_setAndroidContextJni as *mut c_void,
10608        },
10609        #[cfg(target_os = "android")]
10610        NativeMethod {
10611            name: "verifyAndroidContextJni".into(),
10612            sig: "()Z".into(),
10613            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_verifyAndroidContextJni as *mut c_void,
10614        },
10615        #[cfg(feature = "sync")]
10616        NativeMethod {
10617            name: "createNodeJni".into(),
10618            sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)J".into(),
10619            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeJni as *mut c_void,
10620        },
10621        #[cfg(feature = "sync")]
10622        NativeMethod {
10623            name: "getGlobalNodeHandleJni".into(),
10624            sig: "()J".into(),
10625            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getGlobalNodeHandleJni as *mut c_void,
10626        },
10627        #[cfg(feature = "sync")]
10628        NativeMethod {
10629            name: "clearGlobalNodeHandleJni".into(),
10630            sig: "()V".into(),
10631            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_clearGlobalNodeHandleJni as *mut c_void,
10632        },
10633        #[cfg(feature = "sync")]
10634        NativeMethod {
10635            name: "nodeIdJni".into(),
10636            sig: "(J)Ljava/lang/String;".into(),
10637            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_nodeIdJni as *mut c_void,
10638        },
10639        #[cfg(feature = "sync")]
10640        NativeMethod {
10641            name: "peerCountJni".into(),
10642            sig: "(J)I".into(),
10643            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peerCountJni as *mut c_void,
10644        },
10645        #[cfg(feature = "sync")]
10646        NativeMethod {
10647            name: "connectedPeersJni".into(),
10648            sig: "(J)Ljava/lang/String;".into(),
10649            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectedPeersJni as *mut c_void,
10650        },
10651        #[cfg(feature = "sync")]
10652        NativeMethod {
10653            name: "requestSyncJni".into(),
10654            sig: "(J)Z".into(),
10655            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_requestSyncJni as *mut c_void,
10656        },
10657        #[cfg(feature = "sync")]
10658        NativeMethod {
10659            name: "endpointSocketAddrJni".into(),
10660            sig: "(J)Ljava/lang/String;".into(),
10661            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_endpointSocketAddrJni as *mut c_void,
10662        },
10663        #[cfg(feature = "sync")]
10664        NativeMethod {
10665            name: "getDocumentJni".into(),
10666            sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
10667            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getDocumentJni as *mut c_void,
10668        },
10669        #[cfg(feature = "sync")]
10670        NativeMethod {
10671            name: "forceStoreErrorForTestingJni".into(),
10672            sig: "(J)Z".into(),
10673            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_forceStoreErrorForTestingJni
10674                as *mut c_void,
10675        },
10676        #[cfg(feature = "sync")]
10677        NativeMethod {
10678            name: "startSyncJni".into(),
10679            sig: "(J)Z".into(),
10680            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_startSyncJni as *mut c_void,
10681        },
10682        #[cfg(feature = "sync")]
10683        NativeMethod {
10684            name: "freeNodeJni".into(),
10685            sig: "(J)V".into(),
10686            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_freeNodeJni as *mut c_void,
10687        },
10688        #[cfg(feature = "sync")]
10689        NativeMethod {
10690            name: "getCellsJni".into(),
10691            sig: "(J)Ljava/lang/String;".into(),
10692            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCellsJni as *mut c_void,
10693        },
10694        #[cfg(feature = "sync")]
10695        NativeMethod {
10696            name: "getTracksJni".into(),
10697            sig: "(J)Ljava/lang/String;".into(),
10698            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getTracksJni as *mut c_void,
10699        },
10700        #[cfg(feature = "sync")]
10701        NativeMethod {
10702            name: "getNodesJni".into(),
10703            sig: "(J)Ljava/lang/String;".into(),
10704            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getNodesJni as *mut c_void,
10705        },
10706        #[cfg(feature = "sync")]
10707        NativeMethod {
10708            name: "getCommandsJni".into(),
10709            sig: "(J)Ljava/lang/String;".into(),
10710            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCommandsJni as *mut c_void,
10711        },
10712        #[cfg(feature = "sync")]
10713        NativeMethod {
10714            name: "publishNodeJni".into(),
10715            sig: "(JLjava/lang/String;)Z".into(),
10716            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishNodeJni as *mut c_void,
10717        },
10718        #[cfg(feature = "sync")]
10719        NativeMethod {
10720            name: "getMarkersJni".into(),
10721            sig: "(J)Ljava/lang/String;".into(),
10722            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getMarkersJni as *mut c_void,
10723        },
10724        #[cfg(feature = "sync")]
10725        NativeMethod {
10726            name: "publishMarkerJni".into(),
10727            sig: "(JLjava/lang/String;)Z".into(),
10728            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishMarkerJni as *mut c_void,
10729        },
10730        #[cfg(feature = "sync")]
10731        NativeMethod {
10732            name: "publishDocumentJni".into(),
10733            sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
10734            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishDocumentJni as *mut c_void,
10735        },
10736        #[cfg(feature = "sync")]
10737        NativeMethod {
10738            name: "publishDocumentWithOriginJni".into(),
10739            sig: "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"
10740                .into(),
10741            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishDocumentWithOriginJni
10742                as *mut c_void,
10743        },
10744        #[cfg(all(feature = "sync", feature = "bluetooth"))]
10745        NativeMethod {
10746            name: "ingestPositionJni".into(),
10747            sig: "(JLjava/lang/String;)Ljava/lang/String;".into(),
10748            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestPositionJni as *mut c_void,
10749        },
10750        #[cfg(all(feature = "sync", feature = "bluetooth"))]
10751        NativeMethod {
10752            name: "ingestInboundFrameJni".into(),
10753            sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
10754            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundFrameJni as *mut c_void,
10755        },
10756        #[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
10757        NativeMethod {
10758            name: "ingestInboundLiteFrameJni".into(),
10759            sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
10760            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundLiteFrameJni as *mut c_void,
10761        },
10762        #[cfg(feature = "sync")]
10763        NativeMethod {
10764            name: "connectPeerJni".into(),
10765            sig: "(JLjava/lang/String;Ljava/lang/String;)Z".into(),
10766            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectPeerJni as *mut c_void,
10767        },
10768        #[cfg(feature = "sync")]
10769        NativeMethod {
10770            name: "createNodeWithConfigJni".into(),
10771            sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)J"
10772                .into(),
10773            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeWithConfigJni as *mut c_void,
10774        },
10775        // peat#925: the four subscription methods
10776        // (subscribe/unsubscribeDocumentChangesJni,
10777        // subscribe/unsubscribeOutboundFramesJni) are intentionally NOT
10778        // registered via nativeInit because their signatures reference
10779        // consumer-supplied listener interfaces
10780        // (`com/defenseunicorns/peat/DocumentChangeListener`,
10781        // `com/defenseunicorns/peat/OutboundFrameListener`) that don't
10782        // exist in peat-ffi's own `PeatJni.kt` — see the comment block at
10783        // peat-ffi/android/src/main/kotlin/.../PeatJni.kt:27-34 which
10784        // documents the "consumers declare these externs locally" pattern.
10785        //
10786        // The Rust extern fns `Java_com_defenseunicorns_peat_PeatJni_*`
10787        // are still exported and reachable via JNI's auto-lookup-by-name
10788        // convention: any consumer (peat-atak-plugin, downstream apps)
10789        // that declares `external fun subscribeDocumentChangesJni(...)`
10790        // alongside its `DocumentChangeListener` interface gets the
10791        // function resolved via dlsym at first call.
10792        //
10793        // Why these were here: ADR-059 Slice 1.b's outbound-frame
10794        // wiring was developed against a peat-atak-plugin build that
10795        // DID declare the listener interfaces; the `NativeMethod`
10796        // entries were copy-pasted from that build's lockstep
10797        // registration table without re-checking peat-ffi's own
10798        // PeatJni.kt surface.
10799        //
10800        // What went wrong: `JNI_OnLoad → nativeInit → RegisterNatives`
10801        // tries to bind every entry to a corresponding member on
10802        // `com.defenseunicorns.peat.PeatJni`. The DocumentChangeListener
10803        // / OutboundFrameListener signatures reference Kotlin classes
10804        // that don't exist. CheckJNI (active on debug-instrumented
10805        // builds, which is the AndroidJUnit harness configuration on
10806        // the Galaxy Tab A9+ CI runner) aborts the process on
10807        // registration mismatch — `Fatal signal 6 (SIGABRT), code -1
10808        // (SI_QUEUE)` in tid == JUnit-runner-tid, ~12ms after
10809        // `System.loadLibrary("peat_ffi")` returns. The post-
10810        // IrohTransport timing of the abort in earlier logcats was
10811        // misleading — the actual fault is during `System.loadLibrary`
10812        // which the test harness only logs after the abort propagates.
10813        // Blob transfer (ADR-060)
10814        #[cfg(feature = "sync")]
10815        NativeMethod {
10816            name: "enableBlobTransferJni".into(),
10817            sig: "(JLjava/lang/String;)Z".into(),
10818            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_enableBlobTransferJni as *mut c_void,
10819        },
10820        #[cfg(feature = "sync")]
10821        NativeMethod {
10822            name: "blobAddPeerJni".into(),
10823            sig: "(JLjava/lang/String;Ljava/lang/String;)Z".into(),
10824            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobAddPeerJni as *mut c_void,
10825        },
10826        #[cfg(feature = "sync")]
10827        NativeMethod {
10828            name: "blobPutJni".into(),
10829            sig: "(J[BLjava/lang/String;)Ljava/lang/String;".into(),
10830            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobPutJni as *mut c_void,
10831        },
10832        #[cfg(feature = "sync")]
10833        NativeMethod {
10834            name: "blobGetJni".into(),
10835            sig: "(JLjava/lang/String;)[B".into(),
10836            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobGetJni as *mut c_void,
10837        },
10838        #[cfg(feature = "sync")]
10839        NativeMethod {
10840            name: "blobExistsLocallyJni".into(),
10841            sig: "(JLjava/lang/String;)Z".into(),
10842            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobExistsLocallyJni as *mut c_void,
10843        },
10844        #[cfg(feature = "sync")]
10845        NativeMethod {
10846            name: "blobEndpointIdJni".into(),
10847            sig: "(J)Ljava/lang/String;".into(),
10848            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blobEndpointIdJni as *mut c_void,
10849        },
10850        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10851        NativeMethod {
10852            name: "bleSetStartedJni".into(),
10853            sig: "(JZ)V".into(),
10854            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleSetStartedJni as *mut c_void,
10855        },
10856        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10857        NativeMethod {
10858            name: "bleAddPeerJni".into(),
10859            sig: "(JLjava/lang/String;)V".into(),
10860            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleAddPeerJni as *mut c_void,
10861        },
10862        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10863        NativeMethod {
10864            name: "bleRemovePeerJni".into(),
10865            sig: "(JLjava/lang/String;)V".into(),
10866            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleRemovePeerJni as *mut c_void,
10867        },
10868        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10869        NativeMethod {
10870            name: "bleIsAvailableJni".into(),
10871            sig: "(J)Z".into(),
10872            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleIsAvailableJni as *mut c_void,
10873        },
10874        #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
10875        NativeMethod {
10876            name: "blePeerCountJni".into(),
10877            sig: "(J)I".into(),
10878            fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blePeerCountJni as *mut c_void,
10879        },
10880    ];
10881
10882    // Register native methods - the class is passed in from Kotlin so it's valid
10883    if let Err(_e) = env.register_native_methods(&class, &methods) {
10884        // Log error but don't crash - caller will see methods not registered
10885        let _ = env.exception_describe();
10886        let _ = env.exception_clear();
10887    }
10888}
10889
10890/// Bridge `tracing` events into android logcat (peat#850).
10891///
10892/// peat-mesh and peat-protocol emit per-doc sync results, transport
10893/// errors, and other diagnostics via `tracing::error!` /
10894/// `tracing::warn!` / `tracing::info!` / `tracing::debug!`. Without
10895/// a subscriber installed these events go nowhere on Android — which
10896/// is how the marker-sync silent-failure bug went un-diagnosed until
10897/// peat-ffi `request_sync` got its own `android_log` (peat#848).
10898///
10899/// This subscriber routes every tracing event matching the filter
10900/// to logcat under the `PeatRust` tag, **with the tracing `Level`
10901/// mapped to the corresponding Android log priority** so
10902/// `adb logcat *:W` / `*:E` priority filtering surfaces peat-mesh's
10903/// `warn!` / `error!` events. Priority mapping (Android NDK
10904/// convention): `ERROR→6, WARN→5, INFO→4, DEBUG→3, TRACE→2`.
10905///
10906/// Implementation uses a custom `tracing_subscriber::Layer<S>` impl
10907/// (not the `fmt-layer` + custom `Write` pipeline) because the
10908/// formatted-bytes interface only sees the rendered string, not the
10909/// originating `Event`'s metadata. The Layer pulls
10910/// `event.metadata().level()` directly and dispatches to
10911/// `__android_log_write` with the mapped priority. peat#851 round-5.
10912///
10913/// Idempotent via `OnceLock` — safe to call multiple times. Failures
10914/// to install (another subscriber already global) are logged once
10915/// and ignored, never panic.
10916///
10917/// The level defaults to INFO; override with `PEAT_TRACING_LEVEL=debug`
10918/// (or any `tracing-subscriber::EnvFilter` directive) at process
10919/// launch via an environment variable on the Android side. Going
10920/// below INFO is verbose — fine for active diagnostic, not for
10921/// steady-state.
10922#[cfg(target_os = "android")]
10923fn init_android_tracing() {
10924    use std::sync::OnceLock;
10925    static INITIALIZED: OnceLock<()> = OnceLock::new();
10926    INITIALIZED.get_or_init(|| {
10927        use std::ffi::CString;
10928        use std::fmt::Write as _;
10929        use std::os::raw::c_char;
10930        use tracing::field::{Field, Visit};
10931        use tracing::{Event, Level, Subscriber};
10932        use tracing_subscriber::layer::{Context, SubscriberExt};
10933        use tracing_subscriber::util::SubscriberInitExt;
10934        use tracing_subscriber::{EnvFilter, Layer};
10935
10936        extern "C" {
10937            fn __android_log_write(prio: i32, tag: *const c_char, text: *const c_char) -> i32;
10938        }
10939
10940        // Tag is a compile-time constant — allocate the CString once
10941        // for the lifetime of the process, not on every log event.
10942        fn tag_ptr() -> *const c_char {
10943            static TAG: OnceLock<CString> = OnceLock::new();
10944            TAG.get_or_init(|| CString::new("PeatRust").expect("static tag"))
10945                .as_ptr()
10946        }
10947
10948        /// Visitor that flattens an event's fields into a single
10949        /// string. Treats the `message` field (where `info!("X")`'s
10950        /// argument lands) specially so it's not prefixed with
10951        /// `message=`. Other fields render as `name=value`.
10952        #[derive(Default)]
10953        struct FieldStringifier(String);
10954        impl Visit for FieldStringifier {
10955            fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
10956                if !self.0.is_empty() {
10957                    self.0.push(' ');
10958                }
10959                if field.name() == "message" {
10960                    // Debug-format strips the surrounding quotes if
10961                    // the value is a `&str` literal, which matches
10962                    // how the fmt-layer rendered messages previously.
10963                    let _ = write!(self.0, "{:?}", value);
10964                } else {
10965                    let _ = write!(self.0, "{}={:?}", field.name(), value);
10966                }
10967            }
10968            fn record_str(&mut self, field: &Field, value: &str) {
10969                if !self.0.is_empty() {
10970                    self.0.push(' ');
10971                }
10972                if field.name() == "message" {
10973                    self.0.push_str(value);
10974                } else {
10975                    let _ = write!(self.0, "{}={}", field.name(), value);
10976                }
10977            }
10978        }
10979
10980        /// `Level → Android NDK priority` mapping. Verbose=2,
10981        /// Debug=3, Info=4, Warn=5, Error=6. Constants live in
10982        /// `android/log.h`; we hardcode them rather than pulling in
10983        /// the `ndk-sys` crate just for five integers.
10984        fn android_priority(level: &Level) -> i32 {
10985            match *level {
10986                Level::ERROR => 6,
10987                Level::WARN => 5,
10988                Level::INFO => 4,
10989                Level::DEBUG => 3,
10990                Level::TRACE => 2,
10991            }
10992        }
10993
10994        struct AndroidLayer;
10995        impl<S: Subscriber> Layer<S> for AndroidLayer {
10996            fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
10997                let metadata = event.metadata();
10998                let prio = android_priority(metadata.level());
10999
11000                let mut visitor = FieldStringifier::default();
11001                event.record(&mut visitor);
11002                // Prefix with the target (typically the source crate
11003                // / module path) so a logcat reader can grep for
11004                // `peat_mesh::storage::automerge_sync` without
11005                // needing the priority signal alone.
11006                let formatted = if visitor.0.is_empty() {
11007                    metadata.target().to_string()
11008                } else {
11009                    format!("{}: {}", metadata.target(), visitor.0)
11010                };
11011
11012                // Cap each entry well under logcat's per-line limit
11013                // (~4 KiB). The source string is valid UTF-8, so we
11014                // must truncate on a char boundary — walk back from
11015                // byte LIMIT to a UTF-8 leading byte. Worst case 3
11016                // bytes back, O(1).
11017                const LIMIT: usize = 3500;
11018                let bytes = formatted.as_bytes();
11019                let truncated: &[u8] = if bytes.len() > LIMIT {
11020                    let mut cut = LIMIT;
11021                    while cut > 0 && (bytes[cut] & 0b1100_0000) == 0b1000_0000 {
11022                        cut -= 1;
11023                    }
11024                    &bytes[..cut]
11025                } else {
11026                    bytes
11027                };
11028
11029                if let Ok(c_msg) = CString::new(truncated) {
11030                    unsafe {
11031                        __android_log_write(prio, tag_ptr(), c_msg.as_ptr());
11032                    }
11033                }
11034            }
11035        }
11036
11037        let env_filter = EnvFilter::try_from_env("PEAT_TRACING_LEVEL")
11038            .unwrap_or_else(|_| EnvFilter::new("info"));
11039
11040        let result = tracing_subscriber::registry()
11041            .with(env_filter)
11042            .with(AndroidLayer)
11043            .try_init();
11044
11045        match result {
11046            Ok(()) => android_log("init_android_tracing: subscriber installed"),
11047            Err(e) => android_log(&format!(
11048                "init_android_tracing: subscriber NOT installed (already set?): {}",
11049                e
11050            )),
11051        }
11052    });
11053}
11054
11055/// Install a `std::panic::set_hook` that writes the panic payload +
11056/// file:line + (best-effort) backtrace to logcat under the `PeatFFI`
11057/// tag before chaining to the default handler. Idempotent via
11058/// `OnceLock`.
11059///
11060/// Why this exists: on Android, the default panic handler writes to
11061/// stderr which logcat never captures, so an `unwrap()` in a worker
11062/// thread aborts the process with only a bionic SIGABRT trace whose
11063/// frames are stripped Rust symbols. With this hook installed, the
11064/// panic message + source location lands in the existing PeatFFI
11065/// logcat stream that AndroidJUnit and `adb logcat` already
11066/// surface.
11067#[cfg(target_os = "android")]
11068fn install_android_panic_hook() {
11069    use std::sync::OnceLock;
11070    static INSTALLED: OnceLock<()> = OnceLock::new();
11071    INSTALLED.get_or_init(|| {
11072        let default_hook = std::panic::take_hook();
11073        std::panic::set_hook(Box::new(move |info| {
11074            let payload = info
11075                .payload()
11076                .downcast_ref::<&str>()
11077                .copied()
11078                .or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
11079                .unwrap_or("<non-string panic payload>");
11080            let location = info
11081                .location()
11082                .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
11083                .unwrap_or_else(|| "<unknown location>".to_string());
11084            let thread = std::thread::current();
11085            let thread_name = thread.name().unwrap_or("<unnamed>");
11086            android_log(&format!(
11087                "PANIC in thread '{}' at {}: {}",
11088                thread_name, location, payload
11089            ));
11090            default_hook(info);
11091        }));
11092        android_log("install_android_panic_hook: panic hook installed");
11093    });
11094}
11095
11096/// JNI_OnLoad - Called when library is loaded via System.loadLibrary()
11097///
11098/// This is our chance to register native methods while we have access to
11099/// the JNI environment from inside the library's linker namespace.
11100#[no_mangle]
11101#[allow(non_snake_case)]
11102#[allow(clippy::not_unsafe_ptr_arg_deref)] // JNI ABI requires raw pointer params
11103pub extern "C" fn JNI_OnLoad(vm: *mut JavaVM, _reserved: *mut c_void) -> jint {
11104    // Log that we're being called
11105    #[cfg(target_os = "android")]
11106    android_log("JNI_OnLoad called for peat_ffi");
11107
11108    // Bridge `tracing` events (peat-mesh's per-doc sync warnings,
11109    // peat-protocol's sync coordinator events, etc.) into logcat
11110    // under the `PeatRust` tag. peat#850 — previous attempts at
11111    // tracing init "caused issues" per the prior comment here; this
11112    // implementation uses a minimal in-process writer with no JNI
11113    // re-entry and `try_init` so it's a no-op if another subscriber
11114    // was already set.
11115    #[cfg(target_os = "android")]
11116    init_android_tracing();
11117
11118    // Forward Rust panics to logcat before the default hook aborts
11119    // the process. Without this, an `unwrap()` deep in a worker
11120    // thread aborts with no diagnostic — Android's default panic
11121    // path writes to stderr which logcat never captures, and the
11122    // process exits via SIGABRT with only a bionic backtrace whose
11123    // frames are stripped Rust symbols. peat#925 follow-on: makes
11124    // future panics in the iroh/rustls/aws-lc-rs/redb code paths
11125    // self-diagnose in the existing PeatFFI logcat tag.
11126    #[cfg(target_os = "android")]
11127    install_android_panic_hook();
11128
11129    // Initialize `ndk-context`'s global JavaVM cell. The crate is
11130    // pulled in transitively by the iroh 1.0.0-rc.0 cascade
11131    // (swarm-discovery / iroh-mdns-address-lookup / iroh-dns →
11132    // hickory-resolver) and panics with "android context was not
11133    // initialized" the first time any Android-aware code in that
11134    // subtree resolves the global context. Without this call,
11135    // every `createNodeJni` SIGABRT's mid-bind. Surfaced by the
11136    // panic hook above:
11137    //   PANIC in thread '<unnamed>' at ndk-context-0.1.1/src/lib.rs:72:
11138    //     android context was not initialized
11139    //
11140    // **Safety boundary of the null-context init below.** We pass
11141    // our `JavaVM*` (definitely available — it's the argument to
11142    // JNI_OnLoad) and `null` for the Android `Context` jobject (NOT
11143    // available from JNI_OnLoad — JNI_OnLoad runs before any
11144    // Application/Activity has been instantiated by the framework).
11145    // Code paths that consult only the JVM (mDNS multicast worker,
11146    // swarm-discovery sender, iroh thread attachment) get served by
11147    // this init alone. Code paths that genuinely need the
11148    // *Context* itself — hickory-resolver's Android system-DNS
11149    // probe via ConnectivityManager, NDK asset-manager access,
11150    // app-private file paths — will hit `ndk_context::android_context().context()`
11151    // and panic on the null. Consumers exercising those paths
11152    // (any iroh deployment using DNS-based discovery — relay, pkarr,
11153    // non-mDNS peer lookups) MUST call `setAndroidContextJni` from
11154    // their `Application.onCreate` before `createNodeJni`. peat-ffi's
11155    // own surface tests don't reach those paths, but a downstream
11156    // consumer hitting them without `setAndroidContextJni` would
11157    // get a `PANIC in thread '<unnamed>' at ndk-context-0.1.1/...:
11158    // android context was not initialized` line via the panic hook
11159    // above and a SIGABRT — same diagnostic the null-context
11160    // discovery in this very PR surfaced. peat#925 QA WARNING-1.
11161    #[cfg(target_os = "android")]
11162    unsafe {
11163        ndk_context::initialize_android_context(vm as *mut c_void, std::ptr::null_mut());
11164        android_log("JNI_OnLoad: ndk_context::initialize_android_context(vm, null) done");
11165    }
11166
11167    // Store JavaVM globally for callbacks from any thread
11168    let java_vm = unsafe {
11169        match jni::JavaVM::from_raw(vm) {
11170            Ok(jvm) => jvm,
11171            Err(_) => {
11172                #[cfg(target_os = "android")]
11173                android_log("JNI_OnLoad: Failed to create JavaVM from raw pointer");
11174                return jni::sys::JNI_ERR;
11175            }
11176        }
11177    };
11178    *JAVA_VM.lock().unwrap() = Some(java_vm);
11179
11180    // Get JNIEnv from JavaVM
11181    let mut env = unsafe {
11182        let mut env_ptr: *mut jni::sys::JNIEnv = std::ptr::null_mut();
11183        let get_env_result = (**vm).GetEnv.unwrap()(
11184            vm,
11185            &mut env_ptr as *mut _ as *mut *mut c_void,
11186            JNI_VERSION_1_6 as i32,
11187        );
11188        if get_env_result != jni::sys::JNI_OK as i32 {
11189            #[cfg(target_os = "android")]
11190            android_log("JNI_OnLoad: GetEnv failed");
11191            return jni::sys::JNI_ERR;
11192        }
11193        match JNIEnv::from_raw(env_ptr) {
11194            Ok(env) => env,
11195            Err(_) => {
11196                #[cfg(target_os = "android")]
11197                android_log("JNI_OnLoad: JNIEnv::from_raw failed");
11198                return jni::sys::JNI_ERR;
11199            }
11200        }
11201    };
11202
11203    // Try to find PeerEventManager class and store global reference for callbacks
11204    let peer_event_manager_class = "com/defenseunicorns/peat/PeerEventManager";
11205    match env.find_class(peer_event_manager_class) {
11206        Ok(class) => match env.new_global_ref(class) {
11207            Ok(global_ref) => {
11208                *PEER_EVENT_MANAGER_CLASS.lock().unwrap() = Some(global_ref);
11209                #[cfg(target_os = "android")]
11210                android_log("JNI_OnLoad: PeerEventManager class found and cached");
11211            }
11212            Err(_) => {
11213                #[cfg(target_os = "android")]
11214                android_log("JNI_OnLoad: Failed to create global ref for PeerEventManager");
11215            }
11216        },
11217        Err(_) => {
11218            // CRITICAL: clear the pending ClassNotFoundException
11219            // before any further JNI call. Without this, the very
11220            // next find_class (for PeatJni at line 9418) detects a
11221            // pending exception and the JNI runtime aborts the
11222            // process with SIGABRT. Consumers that don't ship a
11223            // PeerEventManager (anything other than peat-atak-plugin)
11224            // crash at System.loadLibrary("peat_ffi"). Surfaced by
11225            // peat-mesh#145 / peat#887.
11226            let _ = env.exception_clear();
11227            #[cfg(target_os = "android")]
11228            android_log(
11229                "JNI_OnLoad: PeerEventManager class not found (OK if loading before class init)",
11230            );
11231        }
11232    }
11233
11234    #[cfg(target_os = "android")]
11235    android_log("JNI_OnLoad: Got JNIEnv, looking for PeatJni class...");
11236
11237    // Try to find the PeatJni class and register natives
11238    let class_name = "com/defenseunicorns/peat/PeatJni";
11239    match env.find_class(class_name) {
11240        Ok(class) => {
11241            #[cfg(target_os = "android")]
11242            android_log("JNI_OnLoad: Found PeatJni class, registering natives...");
11243
11244            // Register native methods
11245            use jni::NativeMethod;
11246            let methods: Vec<NativeMethod> = vec![
11247                NativeMethod {
11248                    name: "nativeInit".into(),
11249                    sig: "()V".into(),
11250                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_nativeInit as *mut c_void,
11251                },
11252                NativeMethod {
11253                    name: "peatVersion".into(),
11254                    sig: "()Ljava/lang/String;".into(),
11255                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peatVersion as *mut c_void,
11256                },
11257                NativeMethod {
11258                    name: "testJni".into(),
11259                    sig: "()Ljava/lang/String;".into(),
11260                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_testJni as *mut c_void,
11261                },
11262                #[cfg(target_os = "android")]
11263                NativeMethod {
11264                    name: "setAndroidContextJni".into(),
11265                    sig: "(Ljava/lang/Object;)V".into(),
11266                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_setAndroidContextJni
11267                        as *mut c_void,
11268                },
11269                #[cfg(target_os = "android")]
11270                NativeMethod {
11271                    name: "verifyAndroidContextJni".into(),
11272                    sig: "()Z".into(),
11273                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_verifyAndroidContextJni
11274                        as *mut c_void,
11275                },
11276                #[cfg(feature = "sync")]
11277                NativeMethod {
11278                    name: "createNodeJni".into(),
11279                    sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)J".into(),
11280                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeJni as *mut c_void,
11281                },
11282                #[cfg(feature = "sync")]
11283                NativeMethod {
11284                    name: "getGlobalNodeHandleJni".into(),
11285                    sig: "()J".into(),
11286                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getGlobalNodeHandleJni
11287                        as *mut c_void,
11288                },
11289                #[cfg(feature = "sync")]
11290                NativeMethod {
11291                    name: "clearGlobalNodeHandleJni".into(),
11292                    sig: "()V".into(),
11293                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_clearGlobalNodeHandleJni
11294                        as *mut c_void,
11295                },
11296                #[cfg(feature = "sync")]
11297                NativeMethod {
11298                    name: "nodeIdJni".into(),
11299                    sig: "(J)Ljava/lang/String;".into(),
11300                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_nodeIdJni as *mut c_void,
11301                },
11302                #[cfg(feature = "sync")]
11303                NativeMethod {
11304                    name: "peerCountJni".into(),
11305                    sig: "(J)I".into(),
11306                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_peerCountJni as *mut c_void,
11307                },
11308                #[cfg(feature = "sync")]
11309                NativeMethod {
11310                    name: "connectedPeersJni".into(),
11311                    sig: "(J)Ljava/lang/String;".into(),
11312                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectedPeersJni as *mut c_void,
11313                },
11314                #[cfg(feature = "sync")]
11315                NativeMethod {
11316                    name: "requestSyncJni".into(),
11317                    sig: "(J)Z".into(),
11318                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_requestSyncJni as *mut c_void,
11319                },
11320                #[cfg(feature = "sync")]
11321                NativeMethod {
11322                    name: "endpointSocketAddrJni".into(),
11323                    sig: "(J)Ljava/lang/String;".into(),
11324                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_endpointSocketAddrJni
11325                        as *mut c_void,
11326                },
11327                #[cfg(feature = "sync")]
11328                NativeMethod {
11329                    name: "getDocumentJni".into(),
11330                    sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
11331                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getDocumentJni as *mut c_void,
11332                },
11333                #[cfg(feature = "sync")]
11334                NativeMethod {
11335                    name: "forceStoreErrorForTestingJni".into(),
11336                    sig: "(J)Z".into(),
11337                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_forceStoreErrorForTestingJni
11338                        as *mut c_void,
11339                },
11340                #[cfg(feature = "sync")]
11341                NativeMethod {
11342                    name: "startSyncJni".into(),
11343                    sig: "(J)Z".into(),
11344                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_startSyncJni as *mut c_void,
11345                },
11346                #[cfg(feature = "sync")]
11347                NativeMethod {
11348                    name: "freeNodeJni".into(),
11349                    sig: "(J)V".into(),
11350                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_freeNodeJni as *mut c_void,
11351                },
11352                #[cfg(feature = "sync")]
11353                NativeMethod {
11354                    name: "getCellsJni".into(),
11355                    sig: "(J)Ljava/lang/String;".into(),
11356                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCellsJni as *mut c_void,
11357                },
11358                #[cfg(feature = "sync")]
11359                NativeMethod {
11360                    name: "getTracksJni".into(),
11361                    sig: "(J)Ljava/lang/String;".into(),
11362                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getTracksJni as *mut c_void,
11363                },
11364                #[cfg(feature = "sync")]
11365                NativeMethod {
11366                    name: "getNodesJni".into(),
11367                    sig: "(J)Ljava/lang/String;".into(),
11368                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getNodesJni as *mut c_void,
11369                },
11370                #[cfg(feature = "sync")]
11371                NativeMethod {
11372                    name: "getCommandsJni".into(),
11373                    sig: "(J)Ljava/lang/String;".into(),
11374                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getCommandsJni as *mut c_void,
11375                },
11376                #[cfg(feature = "sync")]
11377                NativeMethod {
11378                    name: "getMarkersJni".into(),
11379                    sig: "(J)Ljava/lang/String;".into(),
11380                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_getMarkersJni as *mut c_void,
11381                },
11382                #[cfg(feature = "sync")]
11383                NativeMethod {
11384                    name: "publishMarkerJni".into(),
11385                    sig: "(JLjava/lang/String;)Z".into(),
11386                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishMarkerJni
11387                        as *mut c_void,
11388                },
11389                #[cfg(feature = "sync")]
11390                NativeMethod {
11391                    name: "publishNodeJni".into(),
11392                    sig: "(JLjava/lang/String;)Z".into(),
11393                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishNodeJni
11394                        as *mut c_void,
11395                },
11396                #[cfg(feature = "sync")]
11397                NativeMethod {
11398                    name: "publishDocumentJni".into(),
11399                    sig: "(JLjava/lang/String;Ljava/lang/String;)Ljava/lang/String;".into(),
11400                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_publishDocumentJni
11401                        as *mut c_void,
11402                },
11403                #[cfg(feature = "sync")]
11404                NativeMethod {
11405                    name: "publishDocumentWithOriginJni".into(),
11406                    sig: "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)\
11407                          Ljava/lang/String;"
11408                        .into(),
11409                    fn_ptr:
11410                        Java_com_defenseunicorns_peat_PeatJni_publishDocumentWithOriginJni
11411                            as *mut c_void,
11412                },
11413                #[cfg(all(feature = "sync", feature = "bluetooth"))]
11414                NativeMethod {
11415                    name: "ingestPositionJni".into(),
11416                    sig: "(JLjava/lang/String;)Ljava/lang/String;".into(),
11417                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestPositionJni
11418                        as *mut c_void,
11419                },
11420                #[cfg(all(feature = "sync", feature = "bluetooth"))]
11421                NativeMethod {
11422                    name: "ingestInboundFrameJni".into(),
11423                    sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
11424                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundFrameJni
11425                        as *mut c_void,
11426                },
11427                #[cfg(all(feature = "sync", feature = "bluetooth", feature = "lite-bridge"))]
11428                NativeMethod {
11429                    name: "ingestInboundLiteFrameJni".into(),
11430                    sig: "(JLjava/lang/String;[B)Ljava/lang/String;".into(),
11431                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_ingestInboundLiteFrameJni
11432                        as *mut c_void,
11433                },
11434                #[cfg(feature = "sync")]
11435                NativeMethod {
11436                    name: "connectPeerJni".into(),
11437                    sig: "(JLjava/lang/String;Ljava/lang/String;)Z".into(),
11438                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_connectPeerJni as *mut c_void,
11439                },
11440                #[cfg(feature = "sync")]
11441                NativeMethod {
11442                    name: "createNodeWithConfigJni".into(),
11443                    sig: "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)J"
11444                        .into(),
11445                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_createNodeWithConfigJni
11446                        as *mut c_void,
11447                },
11448                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11449                NativeMethod {
11450                    name: "bleSetStartedJni".into(),
11451                    sig: "(JZ)V".into(),
11452                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleSetStartedJni as *mut c_void,
11453                },
11454                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11455                NativeMethod {
11456                    name: "bleAddPeerJni".into(),
11457                    sig: "(JLjava/lang/String;)V".into(),
11458                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleAddPeerJni as *mut c_void,
11459                },
11460                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11461                NativeMethod {
11462                    name: "bleRemovePeerJni".into(),
11463                    sig: "(JLjava/lang/String;)V".into(),
11464                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleRemovePeerJni as *mut c_void,
11465                },
11466                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11467                NativeMethod {
11468                    name: "bleIsAvailableJni".into(),
11469                    sig: "(J)Z".into(),
11470                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_bleIsAvailableJni as *mut c_void,
11471                },
11472                #[cfg(all(feature = "sync", feature = "bluetooth", target_os = "android"))]
11473                NativeMethod {
11474                    name: "blePeerCountJni".into(),
11475                    sig: "(J)I".into(),
11476                    fn_ptr: Java_com_defenseunicorns_peat_PeatJni_blePeerCountJni as *mut c_void,
11477                },
11478            ];
11479
11480            match env.register_native_methods(&class, &methods) {
11481                Ok(_) => {
11482                    #[cfg(target_os = "android")]
11483                    android_log("JNI_OnLoad: Native methods registered successfully!");
11484                }
11485                Err(_) => {
11486                    #[cfg(target_os = "android")]
11487                    android_log("JNI_OnLoad: Failed to register native methods");
11488                    let _ = env.exception_describe();
11489                    let _ = env.exception_clear();
11490                }
11491            }
11492        }
11493        Err(_) => {
11494            #[cfg(target_os = "android")]
11495            android_log(
11496                "JNI_OnLoad: PeatJni class not found (this is OK if loading before class init)",
11497            );
11498            // Class not loaded yet - this is OK, nativeInit will be called
11499            // later
11500        }
11501    }
11502
11503    JNI_VERSION_1_6
11504}
11505
11506/// Log to Android logcat
11507#[cfg(target_os = "android")]
11508fn android_log(msg: &str) {
11509    use std::ffi::CString;
11510    use std::os::raw::c_char;
11511
11512    let tag = CString::new("PeatFFI").unwrap();
11513    let msg = CString::new(msg).unwrap();
11514
11515    unsafe {
11516        // Android log priority INFO = 4
11517        extern "C" {
11518            fn __android_log_write(prio: i32, tag: *const c_char, text: *const c_char) -> i32;
11519        }
11520        __android_log_write(4, tag.as_ptr(), msg.as_ptr());
11521    }
11522}
11523
11524/// Notify Java PeerEventManager of a peer connected event
11525#[cfg(feature = "sync")]
11526fn notify_peer_connected(peer_id: &str) {
11527    notify_peer_event("notifyPeerConnected", peer_id, None);
11528}
11529
11530/// Notify Java PeerEventManager of a peer disconnected event
11531#[cfg(feature = "sync")]
11532fn notify_peer_disconnected(peer_id: &str, reason: &str) {
11533    notify_peer_event("notifyPeerDisconnected", peer_id, Some(reason));
11534}
11535
11536/// Helper to call PeerEventManager static methods
11537#[cfg(feature = "sync")]
11538fn notify_peer_event(method_name: &str, peer_id: &str, reason: Option<&str>) {
11539    let java_vm_guard = JAVA_VM.lock().unwrap();
11540    let java_vm = match java_vm_guard.as_ref() {
11541        Some(vm) => vm,
11542        None => {
11543            #[cfg(target_os = "android")]
11544            android_log("notify_peer_event: No JavaVM available");
11545            return;
11546        }
11547    };
11548
11549    // Check if we already have the class cached
11550    let mut class_guard = PEER_EVENT_MANAGER_CLASS.lock().unwrap();
11551
11552    // If not cached, try to find it now (lazy loading)
11553    if class_guard.is_none() {
11554        #[cfg(target_os = "android")]
11555        android_log("notify_peer_event: PeerEventManager class not cached, trying to find it...");
11556
11557        // Attach current thread to get env for class lookup
11558        if let Ok(mut env) = java_vm.attach_current_thread() {
11559            let peer_event_manager_class = "com/defenseunicorns/peat/PeerEventManager";
11560            if let Ok(class) = env.find_class(peer_event_manager_class) {
11561                if let Ok(global_ref) = env.new_global_ref(class) {
11562                    *class_guard = Some(global_ref);
11563                    #[cfg(target_os = "android")]
11564                    android_log("notify_peer_event: PeerEventManager class found and cached!");
11565                }
11566            } else {
11567                // Clear the pending ClassNotFoundException for the
11568                // same reason as the JNI_OnLoad branch above
11569                // (peat#887). A consumer without PeerEventManager
11570                // is fine — peer events just don't get notified.
11571                let _ = env.exception_clear();
11572                #[cfg(target_os = "android")]
11573                android_log("notify_peer_event: PeerEventManager class not found");
11574            }
11575        }
11576    }
11577
11578    let class_ref = match class_guard.as_ref() {
11579        Some(c) => c,
11580        None => {
11581            #[cfg(target_os = "android")]
11582            android_log("notify_peer_event: PeerEventManager class not available");
11583            return;
11584        }
11585    };
11586
11587    // Attach current thread to JVM
11588    let mut env = match java_vm.attach_current_thread() {
11589        Ok(env) => env,
11590        Err(e) => {
11591            #[cfg(target_os = "android")]
11592            android_log(&format!(
11593                "notify_peer_event: Failed to attach thread: {:?}",
11594                e
11595            ));
11596            return;
11597        }
11598    };
11599
11600    // Create Java string for peer_id
11601    let peer_id_jstring = match env.new_string(peer_id) {
11602        Ok(s) => s,
11603        Err(_) => {
11604            #[cfg(target_os = "android")]
11605            android_log("notify_peer_event: Failed to create peer_id string");
11606            return;
11607        }
11608    };
11609
11610    // Call the appropriate method
11611    let result = if let Some(reason) = reason {
11612        // notifyPeerDisconnected(String peerId, String reason)
11613        let reason_jstring = match env.new_string(reason) {
11614            Ok(s) => s,
11615            Err(_) => {
11616                #[cfg(target_os = "android")]
11617                android_log("notify_peer_event: Failed to create reason string");
11618                return;
11619            }
11620        };
11621        env.call_static_method(
11622            class_ref,
11623            method_name,
11624            "(Ljava/lang/String;Ljava/lang/String;)V",
11625            &[
11626                JValue::Object(&peer_id_jstring),
11627                JValue::Object(&reason_jstring),
11628            ],
11629        )
11630    } else {
11631        // notifyPeerConnected(String peerId)
11632        env.call_static_method(
11633            class_ref,
11634            method_name,
11635            "(Ljava/lang/String;)V",
11636            &[JValue::Object(&peer_id_jstring)],
11637        )
11638    };
11639
11640    if let Err(e) = result {
11641        #[cfg(target_os = "android")]
11642        android_log(&format!("notify_peer_event: Method call failed: {:?}", e));
11643        let _ = env.exception_describe();
11644        let _ = env.exception_clear();
11645    } else {
11646        #[cfg(target_os = "android")]
11647        android_log(&format!(
11648            "notify_peer_event: {} called for {}",
11649            method_name, peer_id
11650        ));
11651    }
11652}