From a54b383dc0dfb1b02796493a59e90e55abde0e35 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:06:36 +0400 Subject: [PATCH 01/12] fix(bindings): iOS BLE recovers after Bluetooth is turned off and on Two bugs left an iPhone unable to send after a Bluetooth power-cycle: - Powering off unpublishes the GATT service, but isGattServiceReady stayed true, so power-on advertised the service UUID with no service behind it. Peers connected, found nothing and dropped. The poweredOff branches now clear the flag and the per-link state (connections, fragments, subscribers), which CoreBluetooth invalidates without disconnect callbacks. That cleanup is factored out of stop() as clearLinkState(). - Power-on only reported bleStatusChanged(true) from the .starting state. After a power-off the state is .unavailable, so the core never heard BLE was back and drainAndSendFragments (gated on .running) never sent again, while inbound kept working. Power-on now also recovers from .unavailable. Known, not fixed: stop() returns early in .unavailable, so stopping while Bluetooth is off skips cleanup. --- bindings/react-native/ios/BleManager.swift | 65 +++++++++++++++------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index 1279c22fa..77c2b9ffb 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -724,6 +724,35 @@ public class BleManager: NSObject, TransportManager { for peripheral in connections.allPeripherals() { centralManager?.cancelPeripheralConnection(peripheral) } + clearLinkState() + pendingAdvertiseRestart?.cancel() + pendingAdvertiseRestart = nil + lastAdvertiseRestartAt = nil + transportStartAt = nil + lastProactiveScanRefresh = nil + lastForcedBleRefresh = nil + aggressiveDiscoveryStarted = nil + + // Clean up managers + centralManager = nil + peripheralManager = nil + + centralReady = false + peripheralReady = false + isGattServiceReady = false + pendingAdvertiseAfterServiceReady = false + + updateState(.stopped) + emitDiagnostic("info", "BLE transport stopped") + } + + /// Drops every piece of per-link state: connections, fragments, GATT + /// subscribers, bootstrap and service-instance bookkeeping. Used by + /// `stop()` and when the radio powers off, because CoreBluetooth then + /// invalidates every connection and published service without delivering + /// disconnect callbacks; stale entries would keep routing sends into dead + /// links after Bluetooth comes back. + private func clearLinkState() { connections.reset() discoveredPeripherals.removeAll() peripheralRSSI.removeAll() @@ -753,13 +782,6 @@ public class BleManager: NSObject, TransportManager { unknownBootstrapAttempts.removeAll() verifiedNonMeshDevices.removeAll() recentAdvertisementHashes.removeAll() - pendingAdvertiseRestart?.cancel() - pendingAdvertiseRestart = nil - lastAdvertiseRestartAt = nil - transportStartAt = nil - lastProactiveScanRefresh = nil - lastForcedBleRefresh = nil - aggressiveDiscoveryStarted = nil notifyLock.lock() subscribedCentralsById.removeAll() notifyLock.unlock() @@ -772,18 +794,6 @@ public class BleManager: NSObject, TransportManager { serviceInstanceLock.lock() serviceInstanceBindings.removeAll() serviceInstanceLock.unlock() - - // Clean up managers - centralManager = nil - peripheralManager = nil - - centralReady = false - peripheralReady = false - isGattServiceReady = false - pendingAdvertiseAfterServiceReady = false - - updateState(.stopped) - emitDiagnostic("info", "BLE transport stopped") } public func pause() { @@ -2898,7 +2908,10 @@ extension BleManager: CBCentralManagerDelegate { drainAndSendFragments() // If both central and peripheral are ready, mark as running - if peripheralReady && state == .starting { + // `.unavailable` too: after Bluetooth is powered off and back on, the + // core must hear bleStatusChanged(true) again or it never routes + // outbound traffic to BLE (inbound still arrives via the delegates). + if peripheralReady && (state == .starting || state == .unavailable) { updateState(.running) print("[BleManager] ✅ BLE Manager ready - dispatching bleStatusChanged(true)") // "dispatched", not "called": the FFI now runs on the protocol @@ -2912,6 +2925,7 @@ extension BleManager: CBCentralManagerDelegate { print("[BleManager] ⚠️ Bluetooth is powered off") centralReady = false stopScanning(reason: "central_powered_off") + clearLinkState() updateState(.unavailable) notifyBleStatus(false) emitDiagnostic("warning", "Bluetooth is powered off", context: ["state": stateString]) @@ -4087,7 +4101,10 @@ extension BleManager: CBPeripheralManagerDelegate { emitDiagnostic("info", "Peripheral manager powered on and ready") // If both central and peripheral are ready, mark as running - if centralReady && state == .starting { + // `.unavailable` too: after Bluetooth is powered off and back on, the + // core must hear bleStatusChanged(true) again or it never routes + // outbound traffic to BLE (inbound still arrives via the delegates). + if centralReady && (state == .starting || state == .unavailable) { updateState(.running) print("[BleManager] ✅ BLE Manager ready (peripheral) - dispatching bleStatusChanged(true)") // See the central-side note: dispatched, not completed. @@ -4099,6 +4116,12 @@ extension BleManager: CBPeripheralManagerDelegate { print("[BleManager] ⚠️ Bluetooth peripheral is powered off") peripheralReady = false stopAdvertising() + // Powering off unpublishes our GATT service. Without this, power-on + // advertises the UUID with no service behind it (setupGattServer + // sees the stale flag and skips re-adding), so peers connect and drop. + isGattServiceReady = false + pendingAdvertiseAfterServiceReady = false + clearLinkState() updateState(.unavailable) notifyBleStatus(false) emitDiagnostic("warning", "Bluetooth peripheral is powered off", context: ["state": stateString]) From 36415b0183312aaf584d67f290ef0c81214dd506 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:06:36 +0400 Subject: [PATCH 02/12] fix(bindings): Android keeps a peer that reconnected from a new address iOS rotates its random BLE address across a Bluetooth power-cycle. Android verified the peer at the new address but kept redialling the old one, and when that gave up it ran the peer-level teardown: blePeerLost (a false neighbor_lost), dropped the connection role and outbound queue, and removeIdentifiersForAddress deleted the device->address mapping that by then pointed at the live link. - removeIdentifiersForAddress only drops the reverse mapping if it still points at the removed address. - MeshConnectionRegistry.hasOtherLiveLink(deviceId, excluding). - handleDisconnected stops redialling a stale address once the peer is live elsewhere, and finalizeGivenUpPeer then only cleans up that address. StaleAddressRegistryTest covers the registry behaviour. --- .../offlineprotocol/ble/CentralGattClient.kt | 20 ++++++++++ .../ble/MeshConnectionRegistry.kt | 12 +++++- .../ble/StaleAddressRegistryTest.kt | 38 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressRegistryTest.kt diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt index 2a3f6bea3..1bf923546 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt @@ -932,6 +932,16 @@ internal class CentralGattClient( host.clearRssi(address) } + // The peer already reconnected from another address: this one is + // stale, so drop it instead of redialling it and later reporting the + // (live) peer as lost. + val stalePeerId = host.connections.deviceIdForAddress(address) + if (stalePeerId != null && host.connections.hasOtherLiveLink(stalePeerId, address)) { + connectionRetryCount.remove(address) + bleHandler.post { finalizeGivenUpPeer(address, stalePeerId) } + return + } + if (wasConnected && host.isRunning()) { // Increment retry count and calculate backoff val retryCount = (connectionRetryCount[address] ?: 0) + 1 @@ -1016,6 +1026,16 @@ internal class CentralGattClient( // through close first. cancelMtuWatchdog(address) clearServiceInstanceSelection(address) + if (host.connections.hasOtherLiveLink(peerId, address)) { + // Only this address is gone, not the peer: skip the peer-level + // teardown (peer lost, role, outbound queue) the live link needs. + host.connections.removeIdentifiersForAddress(address) + diagnosticEmitter("info", "Dropped stale address for a peer with a live link", mapOf( + "address" to address, + "peerId" to peerId, + )) + return + } try { host.protocol.blePeerLost(peerId) } catch (e: Exception) { diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt index 5c66c30cb..35bd612ca 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/MeshConnectionRegistry.kt @@ -51,10 +51,20 @@ class MeshConnectionRegistry { fun removeIdentifiersForAddress(address: String) { val deviceId = addressToDevice.remove(address) if (deviceId != null) { - deviceToAddress.remove(deviceId) + // Only if it still points here: a peer that came back from a new + // address (iOS rotates its random address across a Bluetooth + // power-cycle) has already re-pointed it to the live link. + deviceToAddress.remove(deviceId, address) } } + /** True when [deviceId] is reachable over a live link at an address other than [excluding]. */ + fun hasOtherLiveLink(deviceId: String, excluding: String): Boolean { + val address = deviceToAddress[deviceId] ?: return false + return address != excluding && + (gattClients.containsKey(address) || serverConnections.contains(address)) + } + fun removeIdentifiersForDevice(deviceId: String) { val address = deviceToAddress.remove(deviceId) if (address != null) { diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressRegistryTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressRegistryTest.kt new file mode 100644 index 000000000..90137a3bb --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressRegistryTest.kt @@ -0,0 +1,38 @@ +package com.offlineprotocol.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A peer that reconnects from a new BLE address (iOS rotates its random address + * across a Bluetooth power-cycle) leaves its old address behind. Giving up on the + * stale address must not orphan the live one or report the live peer as lost. + */ +class StaleAddressRegistryTest { + @Test + fun `dropping a stale address keeps the live mapping for the same peer`() { + val registry = MeshConnectionRegistry() + registry.setDeviceIdentifier("old", "peerA") + registry.setDeviceIdentifier("new", "peerA") + registry.trackServerConnection("new") + + assertTrue(registry.hasOtherLiveLink("peerA", excluding = "old")) + registry.removeIdentifiersForAddress("old") + + assertEquals("new", registry.addressForDevice("peerA")) + assertEquals("peerA", registry.deviceIdForAddress("new")) + } + + @Test + fun `a peer with no other live link is not kept alive`() { + val registry = MeshConnectionRegistry() + registry.setDeviceIdentifier("only", "peerB") + registry.trackServerConnection("only") + + assertFalse(registry.hasOtherLiveLink("peerB", excluding = "only")) + registry.removeIdentifiersForAddress("only") + assertEquals(null, registry.addressForDevice("peerB")) + } +} From ad4661f244e064f0066c56bc4b99a04846704e4b Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:20:57 +0400 Subject: [PATCH 03/12] fix(bindings): iOS BLE stopped while Bluetooth is off stays stopped stop() returned early in .unavailable, leaving both CoreBluetooth managers alive. Now that power-on recovers from .unavailable, the next power-on would move the stopped transport back to .running and report BLE available, overriding disableTransport("ble"). --- bindings/react-native/ios/BleManager.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index 77c2b9ffb..c6cea35ed 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -708,7 +708,10 @@ public class BleManager: NSObject, TransportManager { } private func stopUnsafe() { - guard state == .running || state == .starting else { + // `.unavailable` too: the managers are still alive with Bluetooth off, + // and returning early here would let the next power-on move a stopped + // transport back to `.running` and report BLE available to the core. + guard state == .running || state == .starting || state == .unavailable else { return } From d707ea7541406e710b928c40996261f04e16b3cd Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:21:20 +0400 Subject: [PATCH 04/12] fix(bindings): iOS BLE recovers from a Bluetooth reset as from a power-off Any CoreBluetooth state below poweredOff invalidates every CBPeripheral and clears the local GATT database, and a reset can return straight to poweredOn. .resetting only logged, so isGattServiceReady and the per-link state survived it: power-on advertised the service UUID with nothing behind it. It now takes the poweredOff branch on both managers. --- bindings/react-native/ios/BleManager.swift | 24 +++++++++------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index c6cea35ed..9365ee9f3 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -2924,14 +2924,16 @@ extension BleManager: CBCentralManagerDelegate { emitDiagnostic("info", "Dispatched protocol.bleStatusChanged(true)") } - case .poweredOff: - print("[BleManager] ⚠️ Bluetooth is powered off") + // `.resetting` too: any state below poweredOff invalidates every + // CBPeripheral, and a reset can return straight to poweredOn. + case .poweredOff, .resetting: + print("[BleManager] ⚠️ Bluetooth is \(stateString)") centralReady = false stopScanning(reason: "central_powered_off") clearLinkState() updateState(.unavailable) notifyBleStatus(false) - emitDiagnostic("warning", "Bluetooth is powered off", context: ["state": stateString]) + emitDiagnostic("warning", "Bluetooth is powered off or resetting", context: ["state": stateString]) case .unauthorized: print("[BleManager] ⚠️ Bluetooth is unauthorized") @@ -2949,10 +2951,6 @@ extension BleManager: CBCentralManagerDelegate { notifyBleStatus(false) emitDiagnostic("error", "Bluetooth is not supported", context: ["state": stateString]) - case .resetting: - print("[BleManager] 🔄 Bluetooth is resetting...") - emitDiagnostic("info", "Bluetooth is resetting", context: ["state": stateString]) - case .unknown: print("[BleManager] ❓ Bluetooth state is unknown") emitDiagnostic("info", "Bluetooth state is unknown", context: ["state": stateString]) @@ -4115,8 +4113,10 @@ extension BleManager: CBPeripheralManagerDelegate { emitDiagnostic("info", "Dispatched protocol.bleStatusChanged(true) from peripheral") } - case .poweredOff: - print("[BleManager] ⚠️ Bluetooth peripheral is powered off") + // `.resetting` too: any state below poweredOff clears the local GATT + // database, and a reset can return straight to poweredOn. + case .poweredOff, .resetting: + print("[BleManager] ⚠️ Bluetooth peripheral is \(stateString)") peripheralReady = false stopAdvertising() // Powering off unpublishes our GATT service. Without this, power-on @@ -4127,7 +4127,7 @@ extension BleManager: CBPeripheralManagerDelegate { clearLinkState() updateState(.unavailable) notifyBleStatus(false) - emitDiagnostic("warning", "Bluetooth peripheral is powered off", context: ["state": stateString]) + emitDiagnostic("warning", "Bluetooth peripheral is powered off or resetting", context: ["state": stateString]) case .unauthorized: print("[BleManager] ⚠️ Bluetooth peripheral is unauthorized") @@ -4145,10 +4145,6 @@ extension BleManager: CBPeripheralManagerDelegate { notifyBleStatus(false) emitDiagnostic("error", "Bluetooth peripheral is not supported", context: ["state": stateString]) - case .resetting: - print("[BleManager] 🔄 Bluetooth peripheral is resetting...") - emitDiagnostic("info", "Bluetooth peripheral is resetting", context: ["state": stateString]) - case .unknown: print("[BleManager] ❓ Bluetooth peripheral state is unknown") emitDiagnostic("info", "Bluetooth peripheral state is unknown", context: ["state": stateString]) From a61ed2be9f7bb90c0be717e47d4135bf18048732 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:22:50 +0400 Subject: [PATCH 05/12] test(bindings): Android stale-address disconnect neither redials nor drops the peer Drives the real CentralGattClient disconnect path through Robolectric: the old address of a peer live at a new one is not redialled and the peer is not given up, while a peer with no other link still is. --- .../ble/StaleAddressDisconnectTest.kt | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt new file mode 100644 index 000000000..b868fe76a --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt @@ -0,0 +1,119 @@ +package com.offlineprotocol.ble + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothProfile +import android.os.Handler +import android.os.Looper +import com.offlineprotocol.BleAppTag +import com.offlineprotocol.mesh.MeshController +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBluetoothDevice +import org.robolectric.shadows.ShadowBluetoothGatt +import uniffi.offline_protocol.OfflineProtocol +import java.time.Duration +import java.util.UUID + +/** + * Drives the real [CentralGattClient] disconnect path for a peer that came + * back from a new BLE address (iOS rotates its random address across a + * Bluetooth power-cycle). The old address dropping must neither redial it nor + * give up on the peer: giving up reports a false `neighbor_lost` for a peer + * that is live at the new address. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StaleAddressDisconnectTest { + + private val uuid = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") + private val old = "AA:BB:CC:DD:EE:01" + private val new = "AA:BB:CC:DD:EE:02" + private val host = FakeHost() + + private val client = CentralGattClient( + bleHandler = Handler(Looper.getMainLooper()), + serviceUuid = uuid, + messageCharUuid = uuid, + deviceIdCharUuid = uuid, + identityCharUuid = uuid, + appTagCharUuid = uuid, + appTag = BleAppTag.compute("our-app"), + host = host, + diagnosticEmitter = { _, _, _ -> }, + ) + + private fun gatt(address: String): BluetoothGatt = + ShadowBluetoothGatt.newInstance(ShadowBluetoothDevice.newInstance(address)) + + /** Delivers the disconnect, then runs past every reconnect backoff. */ + private fun disconnect(gatt: BluetoothGatt) { + client.callback.onConnectionStateChange(gatt, 8, BluetoothProfile.STATE_DISCONNECTED) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMinutes(2)) + } + + @Test + fun `the old address dropping neither redials it nor gives up on the live peer`() { + val oldGatt = gatt(old) + host.connections.registerGatt(old, oldGatt) + host.connections.setDeviceIdentifier(old, "peerA") + host.connections.registerGatt(new, gatt(new)) + host.connections.setDeviceIdentifier(new, "peerA") + + disconnect(oldGatt) + + assertEquals("no false peer loss", emptyList(), host.givenUp) + assertEquals("the stale address is not redialled", emptyList(), host.dialed) + assertEquals(new, host.connections.addressForDevice("peerA")) + assertNull(host.connections.deviceIdForAddress(old)) + } + + @Test + fun `a peer with no other link is still given up`() { + // Not registered as a gatt: the stale-callback branch gives up at once. + host.connections.setDeviceIdentifier(old, "peerB") + + disconnect(gatt(old)) + + assertEquals(listOf("peerB"), host.givenUp) + assertNull(host.connections.addressForDevice("peerB")) + } + + private class FakeHost : CentralGattClient.Host { + val givenUp = mutableListOf() + val dialed = mutableListOf() + + // `finalizeGivenUpPeer` catches what this throws, so the test records + // peer loss through `onPeerGivenUp`, which runs right after it. + override val protocol: OfflineProtocol + get() = throw IllegalStateException("no core in this test") + override val connections = MeshConnectionRegistry() + override val pendingInbound = InboundFragmentBuffer(bleThreadCheck = {}) + override val outboundQueue = OutboundFragmentQueue(bleThreadCheck = {}) + override val meshController = MeshController("self", MeshController.MeshConfig(maxConnections = 4)) + @Suppress("DEPRECATION") + override val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() + override val selfDeviceId = "self" + override fun isShuttingDown() = false + override fun isRunning() = true + override fun rssiFor(address: String): Short? = null + override fun clearRssi(address: String) {} + override fun markNonMeshDevice(address: String) {} + override fun refreshAdvertising(reason: String) {} + override fun refreshSelfMetrics() {} + override fun maybeHandleRebalance(trigger: String) {} + override fun drainAndSendFragments() {} + override fun onWriteCompleted(address: String) {} + override fun handleInboundFragment(address: String, data: ByteArray) {} + override fun onPeerMtuNegotiated(address: String, maxPayload: Int) {} + override fun onDeviceIdResolved(address: String, deviceId: String) {} + override fun onPeerGivenUp(address: String, peerId: String) { givenUp += peerId } + override fun connectToDevice(device: BluetoothDevice) { dialed += device.address } + } +} From c53b5408df084008caf1580ea50af35ad5a545dc Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:31:16 +0400 Subject: [PATCH 06/12] fix(bindings): iOS mesh frees its slots when Bluetooth drops every link Power-off, a stack reset and stop() empty the BLE connection registry, but the mesh controller kept counting every link it had before. A device that was at maxConnections came back with no free slots, refused its own returning peers and advertised itself as full. clearLinkState() now forgets the mesh's active links too. --- bindings/react-native/ios/BleManager.swift | 2 ++ bindings/react-native/ios/mesh/Mesh.swift | 16 ++++++++++++++++ .../ios/tests/MeshControllerTests.swift | 14 ++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index 9365ee9f3..567e79517 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -757,6 +757,8 @@ public class BleManager: NSObject, TransportManager { /// links after Bluetooth comes back. private func clearLinkState() { connections.reset() + // The mesh counts the same links; without this it stays full. + meshController.registerAllDisconnected() discoveredPeripherals.removeAll() peripheralRSSI.removeAll() inboundFragments.clear() diff --git a/bindings/react-native/ios/mesh/Mesh.swift b/bindings/react-native/ios/mesh/Mesh.swift index f5707b374..b3dafe0f2 100644 --- a/bindings/react-native/ios/mesh/Mesh.swift +++ b/bindings/react-native/ios/mesh/Mesh.swift @@ -645,6 +645,22 @@ final class MeshController: @unchecked Sendable { } } + /// Forgets every active link at once. For when the radio drops them all + /// without a disconnect callback per link (Bluetooth off, a stack reset, + /// `stop()`): the stale entries would keep the degree at its old value, so + /// a device that was full comes back with no free slots, refuses its own + /// returning peers and advertises itself as full. + func registerAllDisconnected() { + queue.async(flags: .barrier) { + let now = self.timeProvider() + for peerId in self.activeConnections.keys { + self.peersById[peerId]?.lastActivity = now + } + self.activeConnections.removeAll() + self.updateClusterSignature() + } + } + func markPeerActive(_ peerId: String) { queue.async(flags: .barrier) { let now = self.timeProvider() diff --git a/bindings/react-native/ios/tests/MeshControllerTests.swift b/bindings/react-native/ios/tests/MeshControllerTests.swift index 1b2e45844..ea379ecd2 100644 --- a/bindings/react-native/ios/tests/MeshControllerTests.swift +++ b/bindings/react-native/ios/tests/MeshControllerTests.swift @@ -130,5 +130,19 @@ final class MeshControllerTests: XCTestCase { XCTAssertEqual(decision.evictPeerId, "weak") XCTAssertEqual(decision.reason, "swap_bridge_capacity") } + + func testDroppingEveryLinkFreesEverySlot() { + // Bluetooth off drops every link with no disconnect callback per link. + // A mesh still counting them comes back full and refuses its peers. + let controller = MeshController(selfId: "self", config: .init(maxConnections: 2)) + controller.registerConnection(peerId: "a", role: .member) + controller.registerConnection(peerId: "b", role: .member) + XCTAssertFalse(controller.connectionBudgetAvailable()) + + controller.registerAllDisconnected() + + XCTAssertTrue(controller.connectionBudgetAvailable()) + XCTAssertEqual(controller.advertisement().freeSlotEstimate, 2) + } } From dadb1e6bd73921d17b53764a77128fde7fe8b2ff Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:31:16 +0400 Subject: [PATCH 07/12] docs: changelog entry for BLE recovery after a Bluetooth power-cycle --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9057548dd..b9f881011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -345,6 +345,25 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Fixed +- **BLE survives a Bluetooth power-cycle on iOS and Android.** Turning an + iPhone's Bluetooth off and on, or a Bluetooth stack reset, left the pair + unable to talk. On iOS, power-off removes the GATT service, but the bridge + still marked it as published, so power-on advertised a service UUID with + nothing behind it and peers connected and dropped. Power-on also never + reported BLE as available again, so the core stopped sending over BLE + while inbound still arrived. Power-off and reset now drop every per-link + record, because CoreBluetooth invalidates the links without a disconnect + callback for each one. That includes the mesh controller's connection + count: a device that was at its connection limit came back with no free + slots and refused its returning peers. Power-on brings the transport + back to running, and `stop()` while Bluetooth is off now stops it instead + of returning early and letting the next power-on restart a stopped + transport. On Android, an iPhone that came back from a new random address + was verified there, but the old address kept being redialled. When the + redialling gave up, it reported the live peer as lost and deleted the + mapping that pointed at the new link. An address is now dropped on its + own once the peer is live at another one. + - **`import offline_protocol_sdk` works on Windows.** `pyproject.toml` has never installed `bless` on Windows, where it has no backend, and the package imported it on the way in, so the import failed on every Windows From af0448bc82ba71b16e76c375bf167afbaf56ab41 Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:39:02 +0400 Subject: [PATCH 08/12] fix(bindings): iOS reports peers lost when Bluetooth powers off CoreBluetooth drops every link on power-off or reset without a disconnect callback per link, and bleStatusChanged(false) clears only the Rust transport's peer map. A peer that did not come back stayed a neighbor in the core and never produced neighbor_lost. The power-off and reset branches now report each identified peer lost before clearing link state. stop() is left alone: it is reachable from deinit, where notifyBlePeerLost's weak-self capture aborts. --- CHANGELOG.md | 4 +++- bindings/react-native/ios/BleManager.swift | 20 +++++++++++++++++-- .../ios/ble/MeshConnectionRegistry.swift | 7 +++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f881011..d365884fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,7 +355,9 @@ archived by series under [docs/changelog/](docs/changelog/); see the record, because CoreBluetooth invalidates the links without a disconnect callback for each one. That includes the mesh controller's connection count: a device that was at its connection limit came back with no free - slots and refused its returning peers. Power-on brings the transport + slots and refused its returning peers. Each dropped peer is also reported + lost, so one that does not come back produces `neighbor_lost` instead of + staying a neighbor in the core. Power-on brings the transport back to running, and `stop()` while Bluetooth is off now stops it instead of returning early and letting the next power-on restart a stopped transport. On Android, an iPhone that came back from a new random address diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index 567e79517..a109fc08e 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -749,6 +749,22 @@ public class BleManager: NSObject, TransportManager { emitDiagnostic("info", "BLE transport stopped") } + /// Bluetooth powered off or reset: report every identified peer lost, then + /// drop the link state. No disconnect callback arrives for these links, and + /// `bleStatusChanged(false)` clears only the Rust transport's peer map, so + /// without this a peer that does not come back stays a neighbor in the core + /// and never produces `neighbor_lost`. Peers that do come back are announced + /// again on the verified path. Not folded into `clearLinkState()`: `stop()` + /// reaches that from `deinit`, where `notifyBlePeerLost`'s `[weak self]` + /// capture is a hard abort. The second manager's callback finds the + /// registry already empty, so each peer is reported once. + private func dropLinksAfterRadioLoss() { + for deviceId in Set(connections.allPeripheralDeviceIds()) { + notifyBlePeerLost(deviceId: deviceId) + } + clearLinkState() + } + /// Drops every piece of per-link state: connections, fragments, GATT /// subscribers, bootstrap and service-instance bookkeeping. Used by /// `stop()` and when the radio powers off, because CoreBluetooth then @@ -2932,7 +2948,7 @@ extension BleManager: CBCentralManagerDelegate { print("[BleManager] ⚠️ Bluetooth is \(stateString)") centralReady = false stopScanning(reason: "central_powered_off") - clearLinkState() + dropLinksAfterRadioLoss() updateState(.unavailable) notifyBleStatus(false) emitDiagnostic("warning", "Bluetooth is powered off or resetting", context: ["state": stateString]) @@ -4126,7 +4142,7 @@ extension BleManager: CBPeripheralManagerDelegate { // sees the stale flag and skips re-adding), so peers connect and drop. isGattServiceReady = false pendingAdvertiseAfterServiceReady = false - clearLinkState() + dropLinksAfterRadioLoss() updateState(.unavailable) notifyBleStatus(false) emitDiagnostic("warning", "Bluetooth peripheral is powered off or resetting", context: ["state": stateString]) diff --git a/bindings/react-native/ios/ble/MeshConnectionRegistry.swift b/bindings/react-native/ios/ble/MeshConnectionRegistry.swift index 194f6a132..15a0c6373 100644 --- a/bindings/react-native/ios/ble/MeshConnectionRegistry.swift +++ b/bindings/react-native/ios/ble/MeshConnectionRegistry.swift @@ -112,6 +112,13 @@ final class MeshConnectionRegistry: @unchecked Sendable { return has } + func allPeripheralDeviceIds() -> [String] { + lock.lock() + let ids = Array(peripheralDeviceIds.values) + lock.unlock() + return ids + } + func discoveredPeerCount() -> Int { lock.lock() let count = peripheralDeviceIds.count + centralDeviceIds.count From 578a6559c0a0d00e85a5648e3b24a6722958debd Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:48:53 +0400 Subject: [PATCH 09/12] fix(bindings): iOS announces a peer that returns after a Bluetooth power-cycle clearLinkState dropped the link registry but not the handshake state. didDisconnectPeripheral clears announcedPeripherals per link, and power-off delivers no disconnect callbacks, so a peer returning under the same identifier skipped the identity reads and was never announced again after being reported lost. Clear advertisedDeviceIds, verifiedPeerAddresses and announcedPeripherals with the rest of the per-link state, and pin it with a source guard since no Swift test reaches BleManager. --- bindings/react-native/ios/BleManager.swift | 7 +++++ crates/offline-protocol-uniffi/src/lib.rs | 35 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index a109fc08e..1fa4a2e02 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -773,6 +773,13 @@ public class BleManager: NSObject, TransportManager { /// links after Bluetooth comes back. private func clearLinkState() { connections.reset() + // The handshake state too. `didDisconnectPeripheral` normally clears + // it per link; without that callback a peer returning under the same + // identifier hits the `announcedPeripherals` guard, skips the identity + // reads and is never announced again. + advertisedDeviceIds.removeAll() + verifiedPeerAddresses.removeAll() + announcedPeripherals.removeAll() // The mesh counts the same links; without this it stays full. meshController.registerAllDisconnected() discoveredPeripherals.removeAll() diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 897ab5faf..57f37cd99 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -13677,6 +13677,41 @@ mod tests { } } + /// Dropping every link at once (Bluetooth off, a stack reset, `stop()`) + /// clears the handshake state with it. + /// + /// `didDisconnectPeripheral` clears `announcedPeripherals` per link, and + /// the identity reads are skipped for any peripheral still in it. Power-off + /// delivers no disconnect callbacks, so without the clear in + /// `clearLinkState` a peer that comes back under the same identifier (its + /// address did not rotate) is reported lost and then never announced + /// again: no device id, no MTU, no route. No Swift test can reach + /// `BleManager`, and a device only shows it with a peer that did not + /// power-cycle. + #[test] + fn react_native_ios_link_loss_clears_handshake_state() { + let swift = rn_source_code_only("ios/BleManager.swift"); + let start = swift + .find("private func clearLinkState() {") + .expect("BleManager.swift must drop per-link state in clearLinkState"); + let end = start + + swift[start..] + .find("public func pause() {") + .expect("pause() must follow clearLinkState so the slice is its body"); + let body = &swift[start..end]; + for clear in [ + "advertisedDeviceIds.removeAll()", + "verifiedPeerAddresses.removeAll()", + "announcedPeripherals.removeAll()", + ] { + assert!( + body.contains(clear), + "clearLinkState must call {clear}: a peer returning after a Bluetooth \ + power-cycle under the same identifier is otherwise never announced again" + ); + } + } + /// The buffered-inbound event set agrees across TypeScript, Kotlin and /// Swift, and each layer's hold is wired to a flush. /// From cdb49285d6e67dc3e51f69c45892d25be8229f2c Mon Sep 17 00:00:00 2001 From: Mizan Ali Date: Fri, 2 Oct 2026 12:48:53 +0400 Subject: [PATCH 10/12] docs: drop trailing whitespace in the BLE power-cycle changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afd986e62..ba0ff4110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -365,7 +365,7 @@ archived by series under [docs/changelog/](docs/changelog/); see the redialling gave up, it reported the live peer as lost and deleted the mapping that pointed at the new link. An address is now dropped on its own once the peer is live at another one. - + - **A key package the application marks synced keeps its record, so its private key is still destroyed when it expires.** `mls_mark_key_package_synced` deleted the record and left the init key in From fe3ad00da4550e20e71550f359e4801da0a5c64d Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 2 Oct 2026 17:12:13 +0530 Subject: [PATCH 11/12] fix(bindings): iOS publishes its GATT service exactly once per power-on The power-cycle fix resets the "service published" flag on power-off, so power-on adds the service again. That is right only if the stack actually threw the old one away. CoreBluetooth promises a cleared GATT database for states *below* poweredOff, not for poweredOff itself. A build that keeps it gets a second identical service instance on every power-cycle, and the central then has to pick between two of our services behind one link. State restoration has the same hole already: a relaunch restores the published service while every characteristic reference in the manager is nil, so setup walks straight past its early return and adds a duplicate. Clear this app's own services right before the add. It only touches what this process published, so the other SDK apps on the phone keep theirs. While at it, pin the whole power-cycle path with a source guard. BleManager has no unit coverage and the path has never run on a device in CI, and every piece of it is a one-token edit that still compiles: the reset arm folded into power-off, both arms dropping the links, the peripheral arm forgetting the service, the clear before the add, both power-on arms recovering from unavailable, and stop() working from unavailable. Each of those was mutated and the guard fails on every one. --- bindings/react-native/ios/BleManager.swift | 9 +++ crates/offline-protocol-uniffi/src/lib.rs | 83 ++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index 1fa4a2e02..35e9ad4da 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -1494,6 +1494,15 @@ public class BleManager: NSObject, TransportManager { let service = CBMutableService(type: SERVICE_UUID, primary: true) service.characteristics = [messageCharacteristic!, deviceIdCharacteristic!, identityCharacteristic!, appTagCharacteristic!] + // Clear this app's published services first, so this add is the only + // instance. CoreBluetooth documents the local GATT database as cleared + // only below `.poweredOff`, so after a plain power-off a build may keep + // the old service, and a relaunch restores it with every characteristic + // reference here nil. Adding on top of either publishes the service + // twice, and a central then sees two instances behind one link. Only + // this app's services are removed: other SDK apps on the phone + // publish their own. + peripheral.removeAllServices() // Add service to peripheral manager (asynchronous - callback in peripheralManager(_:didAdd:error:)) peripheral.add(service) print("[BleManager] GATT server setup initiated, waiting for service registration callback...") diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index cc5148ddb..fea9241e4 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -13712,6 +13712,89 @@ mod tests { } } + /// A Bluetooth power-off or stack reset drops every link and the published + /// service, and power-on brings the transport back. Each piece is a + /// one-token edit that compiles, `BleManager` has no unit coverage, and + /// the power-cycle path has no automated device run, so this is what + /// holds them: + /// + /// - both managers treat `.resetting` as `.poweredOff`, and both arms drop + /// the links (no per-link disconnect callback arrives, so without it the + /// mesh stays full and a peer that never returns stays a neighbor); + /// - the peripheral arm forgets the published service, or power-on + /// advertises a UUID with nothing behind it; + /// - `setupGattServer` clears this app's services before adding, so the + /// service is never published twice; + /// - both power-on arms recover from `.unavailable`, or the core never + /// hears BLE is back and outbound stays off; + /// - `stop()` stops from `.unavailable`, or the next power-on revives a + /// stopped transport. + #[test] + fn react_native_ios_bluetooth_power_cycle_drops_and_restores_the_transport() { + let swift = rn_source_code_only("ios/BleManager.swift"); + let arm = |delegate: &str| -> &str { + let start = swift + .find(delegate) + .unwrap_or_else(|| panic!("BleManager.swift must implement {delegate}")); + let arm_start = start + + swift[start..] + .find("case .poweredOff, .resetting:") + .unwrap_or_else(|| { + panic!("{delegate} must handle .resetting together with .poweredOff") + }); + let arm_end = arm_start + + swift[arm_start..] + .find("case .unauthorized:") + .expect("the .unauthorized arm must follow the power-off arm"); + &swift[arm_start..arm_end] + }; + let central = arm("public func centralManagerDidUpdateState("); + let peripheral = arm("public func peripheralManagerDidUpdateState("); + for (name, body) in [("central", central), ("peripheral", peripheral)] { + assert!( + body.contains("dropLinksAfterRadioLoss()"), + "the {name} power-off arm must drop every link: no disconnect callback \ + arrives for them" + ); + } + assert!( + peripheral.contains("isGattServiceReady = false"), + "the peripheral power-off arm must forget the published service, or power-on \ + advertises a UUID with nothing behind it" + ); + + let setup_start = swift + .find("private func setupGattServer() -> Bool {") + .expect("BleManager.swift must publish its service in setupGattServer"); + let setup = &swift[setup_start..]; + let remove = setup + .find("peripheral.removeAllServices()") + .expect("setupGattServer must clear this app's services before adding"); + let add = setup + .find("peripheral.add(service)") + .expect("setupGattServer must add the service"); + assert!( + remove < add, + "removeAllServices must run before add, or a kept service is published twice" + ); + + assert_eq!( + swift + .matches("Ready && (state == .starting || state == .unavailable) {") + .count(), + 2, + "both power-on arms must recover from .unavailable, or the core never hears \ + bleStatusChanged(true) after a power-cycle" + ); + assert!( + swift.contains( + "guard state == .running || state == .starting || state == .unavailable else {" + ), + "stop() must stop from .unavailable, or the next power-on revives a stopped \ + transport" + ); + } + /// The buffered-inbound event set agrees across TypeScript, Kotlin and /// Swift, and each layer's hold is wired to a flush. /// From 336445edd0b87263911f249ad3e8cae94cca1028 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 2 Oct 2026 17:12:21 +0530 Subject: [PATCH 12/12] fix(bindings): Android drops a stale address without touching the live peer An iPhone comes back from a Bluetooth power-cycle on a new random address. The central path already learned to drop the old address instead of giving up on the peer. Two things were left half done. First, the stale branch skipped *all* cleanup, not just the peer-level part. The inbound fragments buffered for the dead address, its device-id resolution throttle and the facade's address-keyed staged MTU slots all sat there until some idle sweep got around to them. They are keyed by an address that will never carry traffic again, so drop them on the spot. Everything keyed by the device id stays: that belongs to the live link. The facade gets a separate hook for this, because the existing give-up hook also wipes the per-device MTU slots, which is exactly what must not happen here. Second, the GATT server side never got the guard at all. When the old address's link to our server drops uncleanly, the facade still reported the peer lost, dropped its role and its MTU, and deleted the mapping that by then pointed at the new link. The window is only the supervision timeout, but a stack reset can reconnect well inside it. Same check, same address-only cleanup. The new Robolectric case covers the give-up that is queued before the new link lands, which only the check inside the give-up itself can catch. The facade has no test harness, so its guard is pinned by a source guard. Each change was mutated out and a test failed. --- .../offlineprotocol/ble/BleTransportFacade.kt | 25 ++++++++++++++++ .../offlineprotocol/ble/CentralGattClient.kt | 15 +++++++++- .../CentralGattClientInstanceSelectionTest.kt | 1 + .../ble/StaleAddressDisconnectTest.kt | 24 +++++++++++++++ crates/offline-protocol-uniffi/src/lib.rs | 29 +++++++++++++++++++ 5 files changed, 93 insertions(+), 1 deletion(-) diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt index f74ccad01..7c3a39e73 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt @@ -554,6 +554,15 @@ class BleTransportFacade( if (shuttingDown) return dropStagedPeerMtu(address, peerId) } + + override fun onStaleAddressDropped(address: String) { + // The address-keyed staged slots only. A null device id + // keeps the per-device slots, which the peer's live link + // at its new address owns, and the Rust-side MTU stays + // because no blePeerLost was sent. + if (shuttingDown) return + dropStagedPeerMtu(address, null) + } }, diagnosticEmitter = { level, message, ctx -> emitDiagnostic(level, message, ctx) }, ) @@ -4148,6 +4157,22 @@ class BleTransportFacade( if (!isCleanDisconnect) { lastSeenRssi.remove(address) connections.deviceIdForAddress(address)?.let { peerId -> + if (connections.hasOtherLiveLink(peerId, address)) { + // The peer is live at another address (an iPhone rotates + // its random address across a Bluetooth power-cycle), so + // only this address is gone. Reporting the peer lost here + // would be a false neighbor_lost and would drop the live + // link's role and MTU, as on the central path. Drop only + // what is keyed by this address. + dropStagedPeerMtu(address, null) + connections.removeIdentifiersForAddress(address) + centralClient.clearResolutionAttempt(address) + emitDiagnostic("info", "Dropped stale server address for a peer with a live link", mapOf( + "address" to address, + "peerId" to peerId, + )) + return + } try { protocol.blePeerLost(peerId) } catch (e: Exception) { diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt index 1bf923546..1dbe30c39 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt @@ -140,6 +140,14 @@ internal class CentralGattClient( * per-peer MTU entry). */ fun onPeerGivenUp(address: String, peerId: String) + /** Notify the facade that [address] was dropped while its peer stays + * live at another address (an iPhone rotates its random address + * across a Bluetooth power-cycle). Called from [finalizeGivenUpPeer] + * on the BLE thread instead of [onPeerGivenUp]: the facade drops only + * what it keys by [address]. Anything keyed by the peer's device id + * belongs to the live link and must survive. */ + fun onStaleAddressDropped(address: String) + /** Entry point used by the retry-on-disconnect path to re-attempt * connecting to a known address. Facade enforces the per-device * RSSI / capacity / cooldown gating inside connectToDevice. */ @@ -1028,8 +1036,13 @@ internal class CentralGattClient( clearServiceInstanceSelection(address) if (host.connections.hasOtherLiveLink(peerId, address)) { // Only this address is gone, not the peer: skip the peer-level - // teardown (peer lost, role, outbound queue) the live link needs. + // teardown (peer lost, role, outbound queue) the live link needs, + // and drop only what is keyed by the dead address. The outbound + // queue is keyed by peer id, so it stays for the live link. host.connections.removeIdentifiersForAddress(address) + host.pendingInbound.removeAll(address) + deviceIdResolutionAttempts.remove(address) + host.onStaleAddressDropped(address) diagnosticEmitter("info", "Dropped stale address for a peer with a live link", mapOf( "address" to address, "peerId" to peerId, diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt index fd5701a54..8361f8951 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt @@ -376,6 +376,7 @@ class CentralGattClientInstanceSelectionTest { override fun onPeerMtuNegotiated(address: String, maxPayload: Int) {} override fun onDeviceIdResolved(address: String, deviceId: String) {} override fun onPeerGivenUp(address: String, peerId: String) {} + override fun onStaleAddressDropped(address: String) {} override fun connectToDevice(device: BluetoothDevice) {} } } diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt index b868fe76a..17b5d3f99 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/StaleAddressDisconnectTest.kt @@ -9,6 +9,7 @@ import android.os.Looper import com.offlineprotocol.BleAppTag import com.offlineprotocol.mesh.MeshController import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith @@ -65,6 +66,7 @@ class StaleAddressDisconnectTest { host.connections.setDeviceIdentifier(old, "peerA") host.connections.registerGatt(new, gatt(new)) host.connections.setDeviceIdentifier(new, "peerA") + host.pendingInbound.enqueue(old, byteArrayOf(1)) disconnect(oldGatt) @@ -72,6 +74,25 @@ class StaleAddressDisconnectTest { assertEquals("the stale address is not redialled", emptyList(), host.dialed) assertEquals(new, host.connections.addressForDevice("peerA")) assertNull(host.connections.deviceIdForAddress(old)) + assertEquals("the facade drops the dead address's state", listOf(old), host.staleDropped) + assertFalse("the dead address's inbound is dropped", host.pendingInbound.hasPending(old)) + } + + @Test + fun `a peer that comes back while the give-up is queued is not given up`() { + // A stale callback with no other link yet posts the give-up. The new + // link lands before it runs, so only the check inside the give-up + // itself can keep the live peer. + host.connections.setDeviceIdentifier(old, "peerC") + + client.callback.onConnectionStateChange(gatt(old), 8, BluetoothProfile.STATE_DISCONNECTED) + host.connections.registerGatt(new, gatt(new)) + host.connections.setDeviceIdentifier(new, "peerC") + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMinutes(2)) + + assertEquals("no false peer loss", emptyList(), host.givenUp) + assertEquals(listOf(old), host.staleDropped) + assertEquals(new, host.connections.addressForDevice("peerC")) } @Test @@ -82,12 +103,14 @@ class StaleAddressDisconnectTest { disconnect(gatt(old)) assertEquals(listOf("peerB"), host.givenUp) + assertEquals(emptyList(), host.staleDropped) assertNull(host.connections.addressForDevice("peerB")) } private class FakeHost : CentralGattClient.Host { val givenUp = mutableListOf() val dialed = mutableListOf() + val staleDropped = mutableListOf() // `finalizeGivenUpPeer` catches what this throws, so the test records // peer loss through `onPeerGivenUp`, which runs right after it. @@ -114,6 +137,7 @@ class StaleAddressDisconnectTest { override fun onPeerMtuNegotiated(address: String, maxPayload: Int) {} override fun onDeviceIdResolved(address: String, deviceId: String) {} override fun onPeerGivenUp(address: String, peerId: String) { givenUp += peerId } + override fun onStaleAddressDropped(address: String) { staleDropped += address } override fun connectToDevice(device: BluetoothDevice) { dialed += device.address } } } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index fea9241e4..90565a7b8 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -13795,6 +13795,35 @@ mod tests { ); } + /// An unclean server-side disconnect on Android keeps a peer that is live + /// at another address. An iPhone rotates its random address across a + /// Bluetooth power-cycle, and its old link to our GATT server can drop + /// uncleanly after the new one is up. Reporting the peer lost there is a + /// false `neighbor_lost` and drops the live link's role and MTU. The + /// central-role path has Robolectric coverage; `BleTransportFacade` has no + /// test harness, so this pins the check ahead of the peer-lost call. + #[test] + fn react_native_android_server_disconnect_keeps_a_peer_live_elsewhere() { + let kotlin = rn_source_code_only( + "android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt", + ); + let start = kotlin + .find("private fun handleCentralDisconnectedOnBleThread(") + .expect("BleTransportFacade.kt must handle a server-side disconnect"); + let body = &kotlin[start..]; + let guard = body + .find("if (connections.hasOtherLiveLink(peerId, address)) {") + .expect("the server-side disconnect must check for a live link elsewhere"); + let lost = body + .find("protocol.blePeerLost(peerId)") + .expect("the server-side disconnect still reports a peer with no other link lost"); + assert!( + guard < lost, + "the live-link check must come before blePeerLost, or a peer live at a new \ + address is reported lost" + ); + } + /// The buffered-inbound event set agrees across TypeScript, Kotlin and /// Swift, and each layer's hold is wired to a flush. ///