diff --git a/src/node/mod.rs b/src/node/mod.rs index 340fdcb..225006f 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1065,29 +1065,33 @@ impl Node { .iter() .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) .collect(); - match crate::transport::ble::android_io::android_ble_bridge() { - Some(bridge) => { - for (name, ble_config) in ble_instances { - let transport_id = self.allocate_transport_id(); - let io = crate::transport::ble::android_io::AndroidIo::new( - std::sync::Arc::clone(&bridge), - ); - let mut ble = crate::transport::ble::BleTransport::new( - transport_id, - name, - ble_config, - io, - packet_tx.clone(), - ); - ble.set_local_pubkey(self.identity().pubkey().serialize()); - transports.push(TransportHandle::Ble(ble)); - } - } - None => { - if !ble_instances.is_empty() { - tracing::warn!("BLE configured but no Android radio bridge injected"); - } - } + // Built whether or not a radio bridge exists yet, and without + // capturing the one that does: `AndroidIo` resolves the process-wide + // bridge per operation. The radio is owned by an Android foreground + // service whose lifetime is independent of the node's — it can start + // after the node, and it mints a fresh bridge every time it starts. + // Binding either fact into the transport at construction meant the + // only way to pick up a radio was to stop and rebuild the node, + // which drops every peer and session. Operations attempted while no + // radio is present fail as transport errors and recover on their own + // once one appears. + if !ble_instances.is_empty() + && crate::transport::ble::android_io::android_ble_bridge().is_none() + { + tracing::info!("BLE configured; waiting for the Android radio bridge"); + } + for (name, ble_config) in ble_instances { + let transport_id = self.allocate_transport_id(); + let io = crate::transport::ble::android_io::AndroidIo::from_global(); + let mut ble = crate::transport::ble::BleTransport::new( + transport_id, + name, + ble_config, + io, + packet_tx.clone(), + ); + ble.set_local_pubkey(self.identity().pubkey().serialize()); + transports.push(TransportHandle::Ble(ble)); } } diff --git a/src/transport/ble/android_io.rs b/src/transport/ble/android_io.rs index 8f1186b..6bc6e63 100644 --- a/src/transport/ble/android_io.rs +++ b/src/transport/ble/android_io.rs @@ -387,12 +387,44 @@ pub fn android_ble_bridge() -> Option> { /// macOS/Android-style external-radio backend over [`AndroidBleBridge`]. pub struct AndroidIo { - bridge: Arc, + /// Bridge to use when one was supplied explicitly (tests). In the app this + /// is `None` and every operation resolves [`android_ble_bridge`] instead. + /// + /// Resolving per call is what lets the radio be replaced without rebuilding + /// the node. The Android service mints a fresh bridge each time it starts + /// (a new `BleRadio`, a new JNI handle); if this type captured that `Arc` at + /// construction, a running node would keep driving the dead one, and the + /// only way to adopt the new radio would be to stop and restart the node — + /// dropping every peer and every session with it. + bridge: Option>, } impl AndroidIo { + /// Resolve the bridge from the process-wide slot on each operation. + pub fn from_global() -> Self { + Self { bridge: None } + } + + /// Pin a specific bridge, for tests that drive one directly. pub fn new(bridge: Arc) -> Self { - Self { bridge } + Self { + bridge: Some(bridge), + } + } + + /// The bridge for this operation. `None` once the radio has gone away — + /// callers surface that as a transport error rather than panicking, since + /// it is reachable simply by toggling Bluetooth off mid-operation. + fn bridge(&self) -> Option> { + match &self.bridge { + Some(b) => Some(Arc::clone(b)), + None => android_ble_bridge(), + } + } + + fn require_bridge(&self) -> Result, TransportError> { + self.bridge() + .ok_or_else(|| TransportError::Io(std::io::Error::other("BLE radio not available"))) } } @@ -533,42 +565,40 @@ impl BleIo for AndroidIo { type Scanner = AndroidScanner; async fn listen(&self, _psm: u16) -> Result { + let bridge = self.require_bridge()?; // Android assigns the listener PSM; the `psm` arg from FIPS is ignored. - let os_psm = self.bridge.radio.listen(); + let os_psm = bridge.radio.listen(); if os_psm != 0 { - self.bridge.local_psm.store(os_psm, Ordering::Relaxed); + bridge.local_psm.store(os_psm, Ordering::Relaxed); } - let rx = self - .bridge + let rx = bridge .accept_rx .lock() .unwrap_or_else(|e| e.into_inner()) .take(); Ok(AndroidAcceptor { rx, - radio: Arc::clone(&self.bridge.radio), + radio: Arc::clone(&bridge.radio), }) } async fn connect(&self, addr: &BleAddr, psm: u16) -> Result { + let bridge = self.require_bridge()?; // Substitute the learned per-peer PSM for this address, if known. - let dial_psm = self.bridge.psm_map.resolve(addr, psm); - let connect_id = self.bridge.next_id.fetch_add(1, Ordering::Relaxed); + let dial_psm = bridge.psm_map.resolve(addr, psm); + let connect_id = bridge.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = oneshot::channel(); - self.bridge + bridge .connects .lock() .unwrap_or_else(|e| e.into_inner()) .insert(connect_id, tx); - self.bridge.radio.connect(connect_id, addr, dial_psm); + bridge.radio.connect(connect_id, addr, dial_psm); // FIPS already wraps connect() in a timeout, so we just await the result. match rx.await { - Ok(ep) => Ok(AndroidStream::from_endpoints( - ep, - Arc::clone(&self.bridge.radio), - )), + Ok(ep) => Ok(AndroidStream::from_endpoints(ep, Arc::clone(&bridge.radio))), Err(_) => { - self.bridge + bridge .connects .lock() .unwrap_or_else(|e| e.into_inner()) @@ -581,24 +611,26 @@ impl BleIo for AndroidIo { } async fn start_advertising(&self) -> Result<(), TransportError> { - self.bridge + let bridge = self.require_bridge()?; + bridge .radio - .start_advertising(self.bridge.local_psm.load(Ordering::Relaxed)); + .start_advertising(bridge.local_psm.load(Ordering::Relaxed)); Ok(()) } async fn stop_advertising(&self) -> Result<(), TransportError> { - self.bridge.radio.stop_advertising(); + let bridge = self.require_bridge()?; + bridge.radio.stop_advertising(); Ok(()) } async fn start_scanning(&self) -> Result { + let bridge = self.require_bridge()?; // Re-learn PSMs / adverts each scan cycle (addresses rotate with MAC). - self.bridge.psm_map.clear(); - self.bridge.clear_adverts(); - self.bridge.radio.start_scanning(); - let rx = self - .bridge + bridge.psm_map.clear(); + bridge.clear_adverts(); + bridge.radio.start_scanning(); + let rx = bridge .scan_rx .lock() .unwrap_or_else(|e| e.into_inner())