Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,32 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Fixed

- **BLE survives an iPhone's Bluetooth being turned off and on, on both ends
of the link.** 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. The service is now published again on power-on, after clearing
whatever the stack kept, so it never appears twice. 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. 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 the Android end, 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, or the old address's GATT server link dropped
uncleanly, it reported the live peer as lost and deleted the mapping that
pointed at the new link. Once the peer is live at another address, the old
address is now dropped on its own, with only its address-keyed state. This
does not add handling for an Android phone's own Bluetooth being turned
off and on.

- **React Native holds an interest declared before `start()` and applies it
before the engine starts.** (#472) The engine's start-up exchange offers
every held space with the interest in force at that moment, and a narrowing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -932,6 +940,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
Expand Down Expand Up @@ -1016,6 +1034,21 @@ 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,
// 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,
))
return
}
try {
host.protocol.blePeerLost(peerId)
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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.assertFalse
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")
host.pendingInbound.enqueue(old, byteArrayOf(1))

disconnect(oldGatt)

assertEquals("no false peer loss", emptyList<String>(), host.givenUp)
assertEquals("the stale address is not redialled", emptyList<String>(), 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<String>(), host.givenUp)
assertEquals(listOf(old), host.staleDropped)
assertEquals(new, host.connections.addressForDevice("peerC"))
}

@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)
assertEquals(emptyList<String>(), host.staleDropped)
assertNull(host.connections.addressForDevice("peerB"))
}

private class FakeHost : CentralGattClient.Host {
val givenUp = mutableListOf<String>()
val dialed = mutableListOf<String>()
val staleDropped = mutableListOf<String>()

// `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 onStaleAddressDropped(address: String) { staleDropped += address }
override fun connectToDevice(device: BluetoothDevice) { dialed += device.address }
}
}
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading
Loading