diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9720c7b2..7256340f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -845,7 +845,7 @@ jobs: ble/BleDiscoveryBootstrapPolicy.swift \ PeripheralRestorationAgeOutPolicy.swift \ BleMessageNotificationPolicy.swift \ - BleAppTag.swift BleServiceInstanceSelection.swift \ + BleAppTag.swift BleDensityPolicy.swift BleServiceInstanceSelection.swift \ Generated/offline_protocol.swift ForcedPresenceCheckQueue.swift \ AddressDeclarationPolicy.swift \ GatewayAttachPolicy.swift GatewayVerdictTracker.swift \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 38042403..f4531122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -790,6 +790,34 @@ archived by series under [docs/changelog/](docs/changelog/); see the slice is held to 14.0, the oldest system that simulator has. The SDK's own Rust code is compiled for the pod's target as well, where it was compiled for the Rust target's floor of iOS 10. +- **Two phones in a room full of other Bluetooth devices find each other.** + The dense-mesh filters on iOS and Android counted every advert in range, + televisions, earbuds and watches included, as mesh density, and in a + "dense" mesh they passed over up to 80% of the peers, chosen by a hash of + the peer's address alone. On two Android phones a metre apart in a house, + the estimate read 32, and each passed the other over on every advert for + the fifteen minutes they were watched. Density now counts the distinct + mesh candidates seen recently, so a few phones never read + as dense, and a peer passed over in a crowded mesh is reconsidered after a + minute. Both platforms compute the pass-over the same way. A toggle + while the app is paused drops the dead links but leaves bringing + Bluetooth back to the resume. +- **Android: Bluetooth switched off and on, or a Bluetooth stack crash, no + longer strands the mesh.** The transport polled the adapter once a minute, + so it noticed Bluetooth going off up to a minute late, came back on its + recovery ladder (12 to 29 s after Bluetooth returned on two phones), and + never noticed a stack crash at all: on an Android 13 phone the stack + restarted in under a second and took this app's GATT server, advertiser + and pending connect with it, and the other phone could not reach it until + the app restarted. It now listens for the adapter's state broadcast, which + reports a crash like a toggle. On the way down it reports each peer lost + and drops the dead links, which Android delivers no disconnect for and + which otherwise counted against the connection cap, kept peers mapped to + old addresses, and held GATT client registrations (one phone held five + stale ones after a session of toggles). On the way up it rebuilds the + scan, GATT server and advertising at once. A peer probed while its radio + was coming back is no longer cached as "not a mesh device" for five + minutes either. ### Changed diff --git a/bindings/react-native/MeshSdk.podspec b/bindings/react-native/MeshSdk.podspec index 4da4f88f..7394a121 100644 --- a/bindings/react-native/MeshSdk.podspec +++ b/bindings/react-native/MeshSdk.podspec @@ -34,6 +34,7 @@ Pod::Spec.new do |s| "ios/OutboundFragmentQueue.swift", "ios/AddressDeclarationPolicy.swift", "ios/BleAppTag.swift", + "ios/BleDensityPolicy.swift", "ios/BleMessageNotificationPolicy.swift", "ios/BleServiceInstanceSelection.swift", "ios/PeerIdentityBinding.swift", diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/BleDensityPolicy.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/BleDensityPolicy.kt new file mode 100644 index 00000000..9652a9eb --- /dev/null +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/BleDensityPolicy.kt @@ -0,0 +1,75 @@ +package com.offlineprotocol + +/** + * How crowded the mesh around this device is, and which mesh peers a scan + * passes over while it is crowded. Mirrors ios/BleDensityPolicy.swift. + * + * The count is of distinct mesh candidates, the devices the discovery gate + * admits, not of scan callbacks from every Bluetooth device in range. Counted + * that way, a room with a television, earbuds and a watch read as a dense + * mesh, and two phones a metre apart passed each other over for minutes. + * + * The pass-over is keyed on the address and a rotating slot: steady within a + * slot, so a crowded scan does not churn, and reconsidered in the next. Keyed + * on the address alone, the same peers were passed over for as long as their + * address lasted. + */ +internal object BleDensityPolicy { + /** How long one pass-over decision holds for an address. */ + const val SKIP_SLOT_MS = 60_000L + + /** The share of candidates passed over at the high threshold and above. */ + const val MAX_SKIP_SHARE = 0.8 + + /** + * Records [address] as seen at [now] and returns how many distinct + * candidates [seen] holds from the last [windowMs], dropping older ones. + */ + fun recordAndCount(seen: MutableMap, address: String, now: Long, windowMs: Long): Int { + seen[address] = now + seen.entries.removeIf { now - it.value > windowMs } + return seen.size + } + + /** The share of candidates to pass over with [meshPeerCount] mesh peers in range. */ + fun skipShare(meshPeerCount: Int, lowThreshold: Int, highThreshold: Int): Double { + if (meshPeerCount <= lowThreshold) return 0.0 + val density = (meshPeerCount - lowThreshold).toDouble() + val range = (highThreshold - lowThreshold).toDouble() + return minOf(MAX_SKIP_SHARE, density / range * MAX_SKIP_SHARE) + } + + /** Whether to pass over the candidate at [address] in the slot holding [now]. */ + fun shouldSkip( + address: String, + meshPeerCount: Int, + now: Long, + lowThreshold: Int, + highThreshold: Int, + ): Boolean { + val share = skipShare(meshPeerCount, lowThreshold, highThreshold) + if (share == 0.0) return false + return bucket(address, now / SKIP_SLOT_MS) < share + } + + /** + * Where [address] falls in [0, 1) for [slot], the same on iOS: the + * address's String.hashCode, folded with the slot and mixed by the + * murmur3 finalizer. Unmixed, consecutive slots of one address landed in + * neighbouring buckets, so a peer passed over in one slot mostly stayed + * passed over. + */ + internal fun bucket(address: String, slot: Long): Double { + var h = address.hashCode() xor (slot.toInt() * GOLDEN_GAMMA) + h = h xor (h ushr 16) + h *= MIX_1 + h = h xor (h ushr 13) + h *= MIX_2 + h = h xor (h ushr 16) + return (Integer.toUnsignedLong(h) % 1000L) / 1000.0 + } + + private const val GOLDEN_GAMMA = 0x9E3779B9.toInt() + private const val MIX_1 = 0x85EBCA6B.toInt() + private const val MIX_2 = 0xC2B2AE35.toInt() +} diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/AdapterStateTransition.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/AdapterStateTransition.kt new file mode 100644 index 00000000..fa85cafd --- /dev/null +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/AdapterStateTransition.kt @@ -0,0 +1,28 @@ +package com.offlineprotocol.ble + +import android.bluetooth.BluetoothAdapter + +/** + * What one `BluetoothAdapter.ACTION_STATE_CHANGED` broadcast means for a + * running transport. A Bluetooth stack crash reaches apps as the same + * ON -> TURNING_OFF transition a user's toggle does, so both take the + * radio-lost path. + */ +internal enum class AdapterStateTransition { + /** Every link and registration this app held is gone or going. */ + RADIO_LOST, + + /** The adapter is on again: rebuild now, not on the recovery ladder's next rung. */ + RADIO_BACK, + + /** An intermediate state (turning on, LE-only); nothing to do yet. */ + NONE; + + companion object { + fun of(adapterState: Int): AdapterStateTransition = when (adapterState) { + BluetoothAdapter.STATE_TURNING_OFF, BluetoothAdapter.STATE_OFF -> RADIO_LOST + BluetoothAdapter.STATE_ON -> RADIO_BACK + else -> NONE + } + } +} 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 7c3a39e7..81e209bc 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 @@ -3,7 +3,10 @@ package com.offlineprotocol.ble import android.Manifest import android.bluetooth.* import android.bluetooth.le.* +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.BatteryManager @@ -14,6 +17,7 @@ import android.os.ParcelUuid import android.util.Log import androidx.core.content.ContextCompat import com.offlineprotocol.BleAppTag +import com.offlineprotocol.BleDensityPolicy import com.offlineprotocol.BleDiscoveryBootstrapPolicy import com.offlineprotocol.TransportException import com.offlineprotocol.TransportManager @@ -421,6 +425,12 @@ class BleTransportFacade( // Set only when scan startup observes the adapter off. Generic scan // failures must not rebuild the peripheral or churn a healthy advertiser. private var adapterWasOff = false + // Raised by [pauseUnsafe], lowered by [resumeUnsafe] and every session + // start/stop. BLE thread only. The adapter-state receiver stays registered + // across a pause, and without this a Bluetooth toggle in the background + // restarted the scan, rebuilt the GATT server and reported BLE available + // to a core the module had paused in the same step. + private var paused = false // Last availability state successfully delivered to the Rust core. Null // means this facade has not reported a state in its current session; // [stopUnsafe] clears it, because one facade instance is reused across @@ -737,6 +747,17 @@ class BleTransportFacade( private val globalConnectionAttempts = Collections.synchronizedList(mutableListOf()) /** Current estimated visible peer count */ @Volatile private var estimatedVisiblePeerCount: Int = 0 + /** Last time each mesh candidate (an address the discovery gate admitted) was seen */ + private val recentMeshCandidates = ConcurrentHashMap() + /** + * Mesh peers in range, for the dense-mesh filters. [estimatedVisiblePeerCount] + * counts every advert in range and stays the measure of how busy the air is + * for probing unknown devices; it is not a count of mesh peers. + * Floored by the mesh adverts decoded in the last [MESH_OBSERVATION_TTL_MS] + * (two minutes), so a crowded mesh that went quiet for a moment still + * reads as crowded. + */ + @Volatile private var estimatedMeshPeerCount: Int = 0 /** Last time we updated the peer count estimate */ @Volatile private var lastPeerCountUpdate: Long = 0L @Volatile private var lastMeshAdvertisement: MeshAdvertisementData? = null @@ -1224,6 +1245,7 @@ class BleTransportFacade( // early-returning; a fresh start must explicitly re-open the gate. shuttingDown = false adapterWasOff = false + paused = false // Check permissions with detailed logging Log.i(TAG, "Checking Bluetooth permissions (Android ${Build.VERSION.SDK_INT})...") @@ -1296,6 +1318,7 @@ class BleTransportFacade( if (isScanning) { reportBleAvailability(true, "start") } + registerAdapterStateReceiver() Log.i(TAG, "BLE transport ready - scanning and advertising active") emitDiagnostic( @@ -1345,6 +1368,7 @@ class BleTransportFacade( // maps. The flag is @Volatile, which is sufficient because every // reader is a callback that races with a single BLE-thread writer. shuttingDown = true + unregisterAdapterStateReceiver() // Stop fragment polling — must happen before clearing queues bleHandler.removeCallbacks(fragmentPollingRunnable) @@ -1403,9 +1427,12 @@ class BleTransportFacade( verifiedNonMeshDevices.clear() unknownBootstrapAttempts.clear() recentAdvertisementHashes.clear() + recentMeshCandidates.clear() + estimatedMeshPeerCount = 0 scanRestartCount = 0 lastAdapterReset = 0L adapterWasOff = false + paused = false transportStartAt = 0L lastProactiveScanRefresh = 0L lastForcedBleRefresh = 0L @@ -1449,6 +1476,7 @@ class BleTransportFacade( private fun pauseUnsafe() { // For Android background mode + paused = true stopScanning("pause") bleHandler.removeCallbacks(fragmentPollingRunnable) bleHandler.removeCallbacks(fragmentSweepRunnable) @@ -1462,7 +1490,11 @@ class BleTransportFacade( private fun resumeUnsafe() { // Resume from background + paused = false if (state == TransportState.RUNNING) { + // A radio outage heard while paused left [adapterWasOff] raised; + // the scan start below re-arms the GATT and advertising repair + // through onScanStarted(adapterWasOff). startScanning("resume") bleHandler.post(fragmentPollingRunnable) bleHandler.postDelayed(fragmentSweepRunnable, FRAGMENT_SWEEP_INTERVAL_MS) @@ -1675,6 +1707,149 @@ class BleTransportFacade( }, delay) } + /** + * The adapter's state broadcasts, delivered on the BLE thread. Without + * them the transport learned Bluetooth had gone off only when a later scan + * start failed (up to a minute, on the scan watchdog), learned it was back + * only on the recovery ladder's next rung (12 to 29 s on two phones), and + * never learned of a Bluetooth stack crash at all: the stack restarts in + * under a second, the adapter reads enabled again by the next check, and + * the GATT server, advertiser and pending connects this app held are gone. + * On an Android 13 phone a stack crash left the other phone unable to + * reach it until the app restarted. A crash arrives as an ordinary + * ON -> TURNING_OFF broadcast, which is what this acts on. + */ + private val adapterStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != BluetoothAdapter.ACTION_STATE_CHANGED) return + onAdapterStateChanged( + intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR), + ) + } + } + private var adapterStateReceiverRegistered = false + + private fun registerAdapterStateReceiver() { + if (adapterStateReceiverRegistered) return + val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(adapterStateReceiver, filter, null, bleHandler, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(adapterStateReceiver, filter, null, bleHandler) + } + adapterStateReceiverRegistered = true + } catch (e: Exception) { + // The watchdog and recovery ladder still cover a plain toggle. + Log.w(TAG, "Could not listen for Bluetooth state changes", e) + } + } + + private fun unregisterAdapterStateReceiver() { + if (!adapterStateReceiverRegistered) return + adapterStateReceiverRegistered = false + try { + context.unregisterReceiver(adapterStateReceiver) + } catch (e: IllegalArgumentException) { + // Already unregistered. + } + } + + private fun onAdapterStateChanged(adapterState: Int) { + // RUNNING as well as the barrier: startUnsafe registers before its + // last diagnostic, and a throw there leaves the receiver live on a + // STOPPED transport. + if (shuttingDown || state != TransportState.RUNNING) return + when (AdapterStateTransition.of(adapterState)) { + AdapterStateTransition.RADIO_LOST -> { + if (!adapterWasOff) dropLinksAfterRadioLoss() + adapterWasOff = true + // The scan died with the adapter. Follow it locally, so the + // recovery starts a new one rather than finding one "running". + stopScanning("adapter_off", preserveRecoveryBackoff = true) + reportBleAvailability(false, "adapter_off") + // TURNING_OFF then OFF arms this twice, climbing the ladder two + // rungs; RADIO_BACK resets it, so only an ON never heard pays. + // Paused: the links are dead either way, but bringing anything + // back is resume()'s call, not this receiver's. + if (!paused) scheduleBleRecovery() + } + AdapterStateTransition.RADIO_BACK -> { + if (!adapterWasOff) return + if (paused) { + // Leave the rebuild to resume(), from the bottom rung. + cancelBleRecovery() + return + } + Log.i(TAG, "Bluetooth is back: rebuilding scan, GATT server and advertising") + emitDiagnostic("info", "Bluetooth is back, rebuilding the transport") + cancelBleRecovery() + bleHandler.post(bleRecoveryRunnable) + } + AdapterStateTransition.NONE -> Unit + } + } + + /** + * Bluetooth went off under a running transport: report every identified + * peer lost, then drop the link state, as iOS's dropLinksAfterRadioLoss + * does. Android delivers no disconnect callback for most of those links, + * and each BluetoothGatt still holds a client registration the stack has + * forgotten. Left in place, the dead links counted against the connection + * cap, kept their peers mapped to addresses they no longer use, and leaked + * one registration per link on every toggle (one phone held five). + * Peers that come back are found and verified again from scratch. + * + * Runs once per outage, on the BLE thread: from [adapterStateReceiver], + * or from the adapter-off checks in [startScanning] if they see it first. + * GATT callbacks post to the BLE thread, so none runs concurrently with + * this; one posted before it and run after can still write a single entry + * back (a server connection, a staged MTU), which the peer's next + * disconnect or handshake overwrites. + */ + private fun dropLinksAfterRadioLoss() { + val peerIds = connections.deviceIds() + for (peerId in peerIds) { + try { + protocol.blePeerLost(peerId) + } catch (e: Exception) { + Log.e(TAG, "Error notifying peer lost", e) + } + meshController.registerDisconnection(peerId) + } + connections.forEachGatt { gatt -> + try { + gatt.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing GATT client", e) + } + } + connections.clear() + lastSeenRssi.clear() + pendingInbound.clear() + outboundQueue.clear() + peerMaxPayloads.clear() + peripheralMaxPayloads.clear() + centralPayloadByDevice.clear() + peripheralPayloadByDevice.clear() + writeInFlight.clear() + centralClient.clearAll() + lastSeenMeshAdvertisements.clear() + // A peer probed while its radio was coming back serves no mesh + // service yet; cached as non-mesh, it was skipped for five minutes. + verifiedNonMeshDevices.clear() + unknownBootstrapAttempts.clear() + recentAdvertisementHashes.clear() + recentMeshCandidates.clear() + estimatedMeshPeerCount = 0 + refreshSelfMetrics() + // start() with Bluetooth already off reaches here with nothing to drop. + if (peerIds.isNotEmpty()) { + Log.i(TAG, "Bluetooth went off: dropped ${peerIds.size} peer link(s)") + emitDiagnostic("info", "Dropped Bluetooth links after the radio went off", mapOf("peers" to peerIds.size)) + } + } + private fun startScanning(reason: String = "manual") { if (isScanning) { if (logThrottler.shouldLog("scan_already_running")) { @@ -1691,6 +1866,7 @@ class BleTransportFacade( // below for the genuine race (the adapter can still go off between this // check and the call). if (bluetoothAdapter?.isEnabled != true) { + if (!adapterWasOff) dropLinksAfterRadioLoss() adapterWasOff = true scheduleBleRecovery() reportBleAvailability(false, "adapter_off") @@ -1828,6 +2004,7 @@ class BleTransportFacade( // a BLE-thread `check`, a throwing diagnostic emitter — still // fails loud instead of being reported as an adapter-off. scanCallback = null + if (!adapterWasOff) dropLinksAfterRadioLoss() adapterWasOff = true scheduleBleRecovery() reportBleAvailability(false, "adapter_off_race") @@ -2184,17 +2361,25 @@ class BleTransportFacade( if (!shouldProcessDiscoveredDevice(address, scanRecord, rssi, isConnectable, now)) { return } - + + // Counted after the gate, which is what makes this a mesh candidate + // rather than any Bluetooth device in range, and above the two filters + // below, which shed work and must not hide the peers they skip. + estimatedMeshPeerCount = maxOf( + BleDensityPolicy.recordAndCount(recentMeshCandidates, address, now, ADAPTIVE_PEER_COUNT_WINDOW_MS), + lastSeenMeshAdvertisements.size, + ) + // Adaptive scanning: early RSSI filtering in dense networks if (shouldFilterByRssi(rssi)) { if (logThrottler.shouldLog("adaptive_rssi_filter", intervalMs = 10000)) { - Log.d(TAG, "Adaptive: filtering weak signal (${rssi}dBm) in dense network ($estimatedVisiblePeerCount peers)") + Log.d(TAG, "Adaptive: filtering weak signal (${rssi}dBm) in dense network ($estimatedMeshPeerCount peers)") } return } // Adaptive scanning: probabilistic filtering in very dense networks - if (shouldProbabilisticallySkip(address)) { + if (shouldProbabilisticallySkip(address, now)) { return // Silently skip to reduce log spam in dense networks } @@ -2207,7 +2392,7 @@ class BleTransportFacade( discoveryLogTimestamps[address] = now val hasServiceUuid = serviceUuids?.any { it.uuid == SERVICE_UUID } == true val hasServiceData = serviceData != null - Log.d(TAG, "Discovered device $address RSSI=$rssi (density: $estimatedVisiblePeerCount, hasServiceUuid: $hasServiceUuid, hasServiceData: $hasServiceData)") + Log.d(TAG, "Discovered device $address RSSI=$rssi (density: $estimatedVisiblePeerCount, mesh: $estimatedMeshPeerCount, hasServiceUuid: $hasServiceUuid, hasServiceData: $hasServiceData)") emitDiagnostic( "info", "Discovered BLE device", @@ -2216,6 +2401,7 @@ class BleTransportFacade( "rssi" to rssi, "connectable" to isConnectable, "visiblePeers" to estimatedVisiblePeerCount, + "meshPeers" to estimatedMeshPeerCount, "hasServiceUuid" to hasServiceUuid, "hasServiceData" to hasServiceData, "serviceUuids" to (serviceUuids?.map { it.uuid.toString() } ?: emptyList()) @@ -3735,8 +3921,8 @@ class BleTransportFacade( // In dense networks, apply stricter RSSI filtering val threshold = when { - estimatedVisiblePeerCount > ADAPTIVE_HIGH_DENSITY_THRESHOLD -> -70 - estimatedVisiblePeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD -> ADAPTIVE_MIN_RSSI + estimatedMeshPeerCount > ADAPTIVE_HIGH_DENSITY_THRESHOLD -> -70 + estimatedMeshPeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD -> ADAPTIVE_MIN_RSSI else -> return false // Sparse network - accept all signals } return rssi < threshold @@ -3778,7 +3964,7 @@ class BleTransportFacade( } // In dense networks, apply global rate limiting - if (estimatedVisiblePeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD) { + if (estimatedMeshPeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD) { if (globalAttemptCount >= ADAPTIVE_MAX_CONNECTIONS_PER_MINUTE) { if (logThrottler.shouldLog("adaptive_rate_limit", intervalMs = 5000)) { Log.d(TAG, "Adaptive: rate limiting connections ($globalAttemptCount/$ADAPTIVE_MAX_CONNECTIONS_PER_MINUTE in last minute)") @@ -3796,23 +3982,15 @@ class BleTransportFacade( globalConnectionAttempts.add(now) } - /** Returns true if we should apply probabilistic filtering based on network density. */ - private fun shouldProbabilisticallySkip(address: String): Boolean { - if (estimatedVisiblePeerCount <= ADAPTIVE_LOW_DENSITY_THRESHOLD) { - return false - } - - // Calculate skip probability based on density - val density = (estimatedVisiblePeerCount - ADAPTIVE_LOW_DENSITY_THRESHOLD).toDouble() - val range = (ADAPTIVE_HIGH_DENSITY_THRESHOLD - ADAPTIVE_LOW_DENSITY_THRESHOLD).toDouble() - val skipProbability = minOf(0.8, density / range * 0.8) - - // Use address hash for deterministic selection - val hash = address.hashCode() - val normalizedHash = (kotlin.math.abs(hash) % 1000) / 1000.0 - - return normalizedHash < skipProbability - } + /** Returns true if a dense mesh should pass over [address] for now; see [BleDensityPolicy]. */ + private fun shouldProbabilisticallySkip(address: String, now: Long): Boolean = + BleDensityPolicy.shouldSkip( + address = address, + meshPeerCount = estimatedMeshPeerCount, + now = now, + lowThreshold = ADAPTIVE_LOW_DENSITY_THRESHOLD, + highThreshold = ADAPTIVE_HIGH_DENSITY_THRESHOLD, + ) private fun addressForNodeHash(nodeHash: Long): String? { return lastSeenMeshAdvertisements.entries.firstOrNull { 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 35bd612c..12270070 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 @@ -74,6 +74,9 @@ class MeshConnectionRegistry { fun hasDeviceForAddress(address: String): Boolean = addressToDevice.containsKey(address) + /** Every peer identified on some link, once each. */ + fun deviceIds(): Set = addressToDevice.values.toSet() + fun discoveredPeerCount(): Int = addressToDevice.size fun setPendingRole(address: String, role: MeshRole) { diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/BleDensityPolicyTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/BleDensityPolicyTest.kt new file mode 100644 index 00000000..976a15c7 --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/BleDensityPolicyTest.kt @@ -0,0 +1,99 @@ +package com.offlineprotocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [BleDensityPolicy]: two phones in a room full of other Bluetooth devices + * passed each other over for minutes, because every advert in range counted + * as mesh density and the pass-over was fixed per address. + */ +class BleDensityPolicyTest { + + private val low = 10 + private val high = 50 + + @Test + fun `density counts distinct candidates, not callbacks`() { + val seen = HashMap() + var count = 0 + // One peer advertising ten times a second for five seconds. + for (t in 0L until 5_000L step 100L) { + count = BleDensityPolicy.recordAndCount(seen, "AA:BB:CC:DD:EE:01", t, 5_000L) + } + assertEquals(1, count) + assertEquals(2, BleDensityPolicy.recordAndCount(seen, "AA:BB:CC:DD:EE:02", 5_000L, 5_000L)) + } + + @Test + fun `candidates not seen within the window stop counting`() { + val seen = HashMap() + BleDensityPolicy.recordAndCount(seen, "old", 0L, 5_000L) + assertEquals(1, BleDensityPolicy.recordAndCount(seen, "new", 5_001L, 5_000L)) + } + + @Test + fun `a sparse mesh passes nobody over`() { + assertEquals(0.0, BleDensityPolicy.skipShare(low, low, high), 0.0) + for (i in 0 until 500) { + assertFalse(BleDensityPolicy.shouldSkip("peer-$i", meshPeerCount = 2, now = 0L, lowThreshold = low, highThreshold = high)) + } + } + + @Test + fun `the share rises with density and stops at the cap`() { + assertEquals(0.4, BleDensityPolicy.skipShare(30, low, high), 1e-9) + assertEquals(BleDensityPolicy.MAX_SKIP_SHARE, BleDensityPolicy.skipShare(500, low, high), 0.0) + } + + @Test + fun `a peer passed over in one slot is reconsidered in a later one`() { + // At the cap 80% are passed over per slot. Keyed on the address alone, + // the same 80% were passed over in every slot; each slot is a fresh + // draw now, so over 100 slots (0.8^100 apart) every one comes up. + val slot = BleDensityPolicy.SKIP_SLOT_MS + for (i in 0 until 200) { + val address = "5B:E6:7E:6F:%02X:%02X".format(i / 256, i % 256) + val considered = (0 until 100).count { s -> + !BleDensityPolicy.shouldSkip(address, meshPeerCount = 500, now = s * slot, lowThreshold = low, highThreshold = high) + } + assertTrue("$address was passed over in all 100 slots", considered > 0) + } + } + + @Test + fun `the share passed over in a slot is the configured share`() { + val skipped = (0 until 2_000).count { i -> + BleDensityPolicy.shouldSkip("peer-$i", meshPeerCount = 30, now = 0L, lowThreshold = low, highThreshold = high) + } + // 0.4 of 2000, with room for the hash. + assertTrue("skipped $skipped of 2000", skipped in 700..900) + } + + @Test + fun `one address is not stuck in neighbouring buckets across slots`() { + val buckets = (0L until 10L).map { BleDensityPolicy.bucket("51:AC:A5:39:6C:00", it) } + assertTrue("buckets $buckets", buckets.maxOrNull()!! - buckets.minOrNull()!! > 0.3) + } + + @Test + fun `within a slot the decision holds`() { + val slot = BleDensityPolicy.SKIP_SLOT_MS + val first = BleDensityPolicy.shouldSkip("51:AC:A5:39:6C:00", 30, 3 * slot, low, high) + for (t in 3 * slot until 4 * slot step 1_000L) { + assertEquals(first, BleDensityPolicy.shouldSkip("51:AC:A5:39:6C:00", 30, t, low, high)) + } + } + + @Test + fun `the bucket matches the iOS hash`() { + // ios/tests/BleDensityPolicyTests.swift pins the same values. The two + // hashes are written by hand, and this keeps them to the same bits. + // It is not a runtime agreement: Android keys on the peer's MAC and + // iOS on its own CBPeripheral identifier, never the same string. + assertEquals(0.133, BleDensityPolicy.bucket("51:AC:A5:39:6C:00", 7), 1e-9) + assertEquals(0.314, BleDensityPolicy.bucket("51:AC:A5:39:6C:00", 0), 1e-9) + } +} diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/AdapterStateTransitionTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/AdapterStateTransitionTest.kt new file mode 100644 index 00000000..d0c45f4f --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/AdapterStateTransitionTest.kt @@ -0,0 +1,31 @@ +package com.offlineprotocol.ble + +import android.bluetooth.BluetoothAdapter +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * [AdapterStateTransition]: a Bluetooth stack crash on an Android 13 phone + * reached the app only as ON -> TURNING_OFF, and the transport, which polled + * the adapter once a minute, never noticed it. + */ +class AdapterStateTransitionTest { + @Test + fun `turning off and off are the radio going`() { + assertEquals(AdapterStateTransition.RADIO_LOST, AdapterStateTransition.of(BluetoothAdapter.STATE_TURNING_OFF)) + assertEquals(AdapterStateTransition.RADIO_LOST, AdapterStateTransition.of(BluetoothAdapter.STATE_OFF)) + } + + @Test + fun `on is the radio back`() { + assertEquals(AdapterStateTransition.RADIO_BACK, AdapterStateTransition.of(BluetoothAdapter.STATE_ON)) + } + + @Test + fun `turning on and unknown states wait`() { + assertEquals(AdapterStateTransition.NONE, AdapterStateTransition.of(BluetoothAdapter.STATE_TURNING_ON)) + assertEquals(AdapterStateTransition.NONE, AdapterStateTransition.of(BluetoothAdapter.ERROR)) + // BLE_ON (15), the stack's LE-only state on the way down and up. + assertEquals(AdapterStateTransition.NONE, AdapterStateTransition.of(15)) + } +} 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 index 90137a3b..bd19fe07 100644 --- 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 @@ -35,4 +35,16 @@ class StaleAddressRegistryTest { registry.removeIdentifiersForAddress("only") assertEquals(null, registry.addressForDevice("peerB")) } + + @Test + fun `deviceIds lists each identified peer once`() { + val registry = MeshConnectionRegistry() + registry.setDeviceIdentifier("old", "peerA") + registry.setDeviceIdentifier("new", "peerA") + registry.setDeviceIdentifier("other", "peerB") + + assertEquals(setOf("peerA", "peerB"), registry.deviceIds()) + registry.clear() + assertTrue(registry.deviceIds().isEmpty()) + } } diff --git a/bindings/react-native/ios/BleDensityPolicy.swift b/bindings/react-native/ios/BleDensityPolicy.swift new file mode 100644 index 00000000..ca3ef07a --- /dev/null +++ b/bindings/react-native/ios/BleDensityPolicy.swift @@ -0,0 +1,67 @@ +import Foundation + +/// How crowded the mesh around this device is, and which mesh peers a scan +/// passes over while it is crowded. Mirrors android/.../BleDensityPolicy.kt; +/// keep in sync. +/// +/// The count is of distinct mesh candidates, the peripherals the discovery +/// gate admits, not of discovery callbacks from every Bluetooth device in +/// range. Counted that way, a room with a television, earbuds and a watch +/// read as a dense mesh, and two phones a metre apart passed each other over. +/// +/// The pass-over is keyed on the peripheral and a rotating slot: steady within +/// a slot, so a crowded scan does not churn, and reconsidered in the next. +/// Keyed on `peripheral.hashValue` alone, which Swift seeds once per process, +/// the same peers were passed over for the life of the process. +internal enum BleDensityPolicy { + /// How long one pass-over decision holds for a peripheral. + static let skipSlot: TimeInterval = 60 + + /// The share of candidates passed over at the high threshold and above. + static let maxSkipShare = 0.8 + + /// Records `id` as seen at `now` and returns how many distinct candidates + /// `seen` holds from the last `window`, dropping older ones. + static func recordAndCount(_ seen: inout [String: Date], id: String, now: Date, window: TimeInterval) -> Int { + // Pruned in place, as on Android: this runs on every discovery. + seen[id] = now + for (key, lastSeen) in seen where now.timeIntervalSince(lastSeen) > window { + seen.removeValue(forKey: key) + } + return seen.count + } + + /// The share of candidates to pass over with `meshPeerCount` mesh peers in range. + static func skipShare(meshPeerCount: Int, lowThreshold: Int, highThreshold: Int) -> Double { + guard meshPeerCount > lowThreshold else { return 0 } + let density = Double(meshPeerCount - lowThreshold) + let range = Double(highThreshold - lowThreshold) + return min(maxSkipShare, density / range * maxSkipShare) + } + + /// Whether to pass over the candidate `id` in the slot holding `now`. + static func shouldSkip(id: String, meshPeerCount: Int, now: Date, lowThreshold: Int, highThreshold: Int) -> Bool { + let share = skipShare(meshPeerCount: meshPeerCount, lowThreshold: lowThreshold, highThreshold: highThreshold) + guard share > 0 else { return false } + let slot = Int64((now.timeIntervalSince1970 * 1000).rounded(.down)) / Int64(skipSlot * 1000) + return bucket(id: id, slot: slot) < share + } + + /// Where `id` falls in [0, 1) for `slot`, the same as on Android: Java's + /// String.hashCode over the UTF-16 units, folded with the slot and mixed + /// by the murmur3 finalizer. Unmixed, consecutive slots of one id landed + /// in neighbouring buckets, so a peer passed over once mostly stayed so. + static func bucket(id: String, slot: Int64) -> Double { + var stringHash: UInt32 = 0 + for unit in id.utf16 { + stringHash = 31 &* stringHash &+ UInt32(unit) + } + var h = stringHash ^ (UInt32(truncatingIfNeeded: slot) &* 0x9E37_79B9) + h ^= h >> 16 + h = h &* 0x85EB_CA6B + h ^= h >> 13 + h = h &* 0xC2B2_AE35 + h ^= h >> 16 + return Double(h % 1000) / 1000 + } +} diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index 976bf0e7..38d9bb42 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -381,6 +381,15 @@ public class BleManager: NSObject, TransportManager { private var globalConnectionAttempts: [Date] = [] /// Current estimated visible peer count private var estimatedVisiblePeerCount: Int = 0 + /// Last time each mesh candidate (a peripheral the discovery gate admitted) was seen + private var recentMeshCandidates: [String: Date] = [:] + /// Mesh peers in range, for the dense-mesh filters. `estimatedVisiblePeerCount` + /// counts every discovery in range and stays the measure of how busy the + /// air is for probing unknown peripherals; it is not a count of mesh peers. + /// Floored by the mesh adverts decoded in the last `MESH_OBSERVATION_TTL` + /// (two minutes), so a crowded mesh that went quiet for a moment still + /// reads as crowded. + private var estimatedMeshPeerCount: Int = 0 /// Last time we updated the peer count estimate private var lastPeerCountUpdate: Date? private let SCAN_HEARTBEAT_INTERVAL: TimeInterval = 10.0 @@ -810,6 +819,8 @@ public class BleManager: NSObject, TransportManager { unknownBootstrapAttempts.removeAll() verifiedNonMeshDevices.removeAll() recentAdvertisementHashes.removeAll() + recentMeshCandidates.removeAll() + estimatedMeshPeerCount = 0 notifyLock.lock() subscribedCentralsById.removeAll() notifyLock.unlock() @@ -2390,10 +2401,10 @@ public class BleManager: NSObject, TransportManager { // In dense networks, apply stricter RSSI filtering let threshold: Int16 - if estimatedVisiblePeerCount > ADAPTIVE_HIGH_DENSITY_THRESHOLD { + if estimatedMeshPeerCount > ADAPTIVE_HIGH_DENSITY_THRESHOLD { // Very dense - only consider strong signals threshold = -70 - } else if estimatedVisiblePeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD { + } else if estimatedMeshPeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD { // Moderately dense - standard threshold threshold = ADAPTIVE_MIN_RSSI } else { @@ -2435,7 +2446,7 @@ public class BleManager: NSObject, TransportManager { } // In dense networks, apply global rate limiting - if estimatedVisiblePeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD { + if estimatedMeshPeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD { let maxAttempts = ADAPTIVE_MAX_CONNECTIONS_PER_MINUTE if globalConnectionAttempts.count >= maxAttempts { if logThrottler.shouldLog(key: "adaptive_rate_limit", interval: 5) { @@ -2454,25 +2465,15 @@ public class BleManager: NSObject, TransportManager { globalConnectionAttempts.append(now) } - /// Returns true if we should apply probabilistic filtering based on network density. - /// Uses deterministic pseudo-randomness based on peripheral ID to ensure consistency. - private func shouldProbabilisticallySkip(_ peripheral: UUID) -> Bool { - guard estimatedVisiblePeerCount > ADAPTIVE_LOW_DENSITY_THRESHOLD else { - return false - } - - // Calculate skip probability based on density - // At 50+ peers, skip ~80% of evaluations - // At 10-50 peers, scale linearly - let density = Double(estimatedVisiblePeerCount - ADAPTIVE_LOW_DENSITY_THRESHOLD) - let range = Double(ADAPTIVE_HIGH_DENSITY_THRESHOLD - ADAPTIVE_LOW_DENSITY_THRESHOLD) - let skipProbability = min(0.8, density / range * 0.8) - - // Use peripheral UUID hash for deterministic selection - let hash = peripheral.hashValue - let normalizedHash = Double(abs(hash) % 1000) / 1000.0 - - return normalizedHash < skipProbability + /// Returns true if a dense mesh should pass over `peripheral` for now; see `BleDensityPolicy`. + private func shouldProbabilisticallySkip(_ peripheral: UUID, now: Date) -> Bool { + BleDensityPolicy.shouldSkip( + id: peripheral.uuidString, + meshPeerCount: estimatedMeshPeerCount, + now: now, + lowThreshold: ADAPTIVE_LOW_DENSITY_THRESHOLD, + highThreshold: ADAPTIVE_HIGH_DENSITY_THRESHOLD + ) } // MARK: - Smart Filtering for iOS ↔ Android Interoperability @@ -3048,11 +3049,10 @@ extension BleManager: CBCentralManagerDelegate { // This is the observation the restoration age-out is built on: "this // app was scanning, and it saw this peripheral." Both filters below // are load shedding — they drop work, not observations — and - // `shouldProbabilisticallySkip` keys on `peripheral.hashValue`, which - // Swift seeds once per process. Recording after them would therefore - // hide a FIXED subset of the visible peers, up to 80% of them in a - // scan that reads as dense, for the whole life of the process, while - // their neighbours moved the age-out cutoff forward every second. + // `shouldProbabilisticallySkip` passes over up to 80% of the visible + // peers for a minute at a time in a scan that reads as dense. + // Recording after them would therefore hide those peers while their + // neighbours moved the age-out cutoff forward every second. // Those peers would read as "went quiet while we were watching" at the // next restoration and lose the pending connect that is the only way // iOS wakes this app when one of them reappears — although they had @@ -3065,16 +3065,24 @@ extension BleManager: CBCentralManagerDelegate { // would spend the 200-entry cap on passing headphones. peripheralRestorationPolicy.recordSeen(uuid: peripheral.identifier, at: now, source: .advertisement) + // Mesh density is counted here too, for the same reasons: after the + // gate, so it counts mesh candidates and not every Bluetooth device in + // range, and above the filters it drives. + estimatedMeshPeerCount = max( + BleDensityPolicy.recordAndCount(&recentMeshCandidates, id: peripheral.identifier.uuidString, now: now, window: ADAPTIVE_PEER_COUNT_WINDOW), + lastSeenMeshAdvertisements.count + ) + // Adaptive scanning: early RSSI filtering in dense networks if shouldFilterByRssi(rssiValue) { if logThrottler.shouldLog(key: "adaptive_rssi_filter", interval: 10) { - print("[BleManager] Adaptive: filtering weak signal (\(rssiValue)dBm) in dense network (\(estimatedVisiblePeerCount) peers)") + print("[BleManager] Adaptive: filtering weak signal (\(rssiValue)dBm) in dense network (\(estimatedMeshPeerCount) peers)") } return } // Adaptive scanning: probabilistic filtering in very dense networks - if shouldProbabilisticallySkip(peripheral.identifier) { + if shouldProbabilisticallySkip(peripheral.identifier, now: now) { return // Silently skip to reduce log spam in dense networks } diff --git a/bindings/react-native/ios/Package.swift b/bindings/react-native/ios/Package.swift index 9424b699..d5705334 100644 --- a/bindings/react-native/ios/Package.swift +++ b/bindings/react-native/ios/Package.swift @@ -60,6 +60,7 @@ let package = Package( sources: [ "AddressDeclarationPolicy.swift", "BleAppTag.swift", + "BleDensityPolicy.swift", "BleMessageNotificationPolicy.swift", "BleServiceInstanceSelection.swift", "EncryptionConfigReader.swift", @@ -116,6 +117,7 @@ let package = Package( sources: [ "AddressDeclarationPolicyTests.swift", "BleAppTagTests.swift", + "BleDensityPolicyTests.swift", "BleMessageNotificationPolicyTests.swift", "BleServiceInstanceSelectionTests.swift", "EncryptionConfigReaderTests.swift", diff --git a/bindings/react-native/ios/tests/BleDensityPolicyTests.swift b/bindings/react-native/ios/tests/BleDensityPolicyTests.swift new file mode 100644 index 00000000..07bfa5c0 --- /dev/null +++ b/bindings/react-native/ios/tests/BleDensityPolicyTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import OfflineProtocol + +/// `BleDensityPolicy`: two phones in a room full of other Bluetooth devices +/// passed each other over for minutes, because every advert in range counted +/// as mesh density and the pass-over was fixed per peripheral. Mirrors +/// android/src/test/.../BleDensityPolicyTest.kt. +final class BleDensityPolicyTests: XCTestCase { + private let low = 10 + private let high = 50 + + func testDensityCountsDistinctCandidatesNotCallbacks() { + var seen: [String: Date] = [:] + let start = Date(timeIntervalSince1970: 1_000) + var count = 0 + // One peer advertising ten times a second for five seconds. + for tenth in 0..<50 { + count = BleDensityPolicy.recordAndCount(&seen, id: "peer-1", now: start.addingTimeInterval(Double(tenth) / 10), window: 5) + } + XCTAssertEqual(count, 1) + XCTAssertEqual(BleDensityPolicy.recordAndCount(&seen, id: "peer-2", now: start.addingTimeInterval(5), window: 5), 2) + } + + func testCandidatesNotSeenWithinTheWindowStopCounting() { + var seen: [String: Date] = [:] + let start = Date(timeIntervalSince1970: 1_000) + _ = BleDensityPolicy.recordAndCount(&seen, id: "old", now: start, window: 5) + XCTAssertEqual(BleDensityPolicy.recordAndCount(&seen, id: "new", now: start.addingTimeInterval(5.001), window: 5), 1) + } + + func testASparseMeshPassesNobodyOver() { + XCTAssertEqual(BleDensityPolicy.skipShare(meshPeerCount: low, lowThreshold: low, highThreshold: high), 0) + for i in 0..<500 { + XCTAssertFalse(BleDensityPolicy.shouldSkip(id: "peer-\(i)", meshPeerCount: 2, now: Date(), lowThreshold: low, highThreshold: high)) + } + } + + func testTheShareRisesWithDensityAndStopsAtTheCap() { + XCTAssertEqual(BleDensityPolicy.skipShare(meshPeerCount: 30, lowThreshold: low, highThreshold: high), 0.4, accuracy: 1e-9) + XCTAssertEqual(BleDensityPolicy.skipShare(meshPeerCount: 500, lowThreshold: low, highThreshold: high), BleDensityPolicy.maxSkipShare) + } + + func testAPeerPassedOverInOneSlotIsReconsideredInALaterOne() { + // At the cap 80% are passed over per slot; over 100 slots every + // peripheral comes up, which a fixed per-peripheral hash never did. + for i in 0..<200 { + let id = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, UInt8(i / 256), UInt8(i % 256))).uuidString + let considered = (0..<100).filter { s in + !BleDensityPolicy.shouldSkip(id: id, meshPeerCount: 500, now: Date(timeIntervalSince1970: Double(s) * BleDensityPolicy.skipSlot), lowThreshold: low, highThreshold: high) + }.count + XCTAssertGreaterThan(considered, 0, "\(id) was passed over in all 100 slots") + } + } + + func testTheSharePassedOverInASlotIsTheConfiguredShare() { + let skipped = (0..<2_000).filter { i in + BleDensityPolicy.shouldSkip(id: "peer-\(i)", meshPeerCount: 30, now: Date(timeIntervalSince1970: 0), lowThreshold: low, highThreshold: high) + }.count + // 0.4 of 2000, with room for the hash. + XCTAssertTrue((700...900).contains(skipped), "skipped \(skipped) of 2000") + } + + func testWithinASlotTheDecisionHolds() { + let slotStart = 3 * BleDensityPolicy.skipSlot + let first = BleDensityPolicy.shouldSkip(id: "51:AC:A5:39:6C:00", meshPeerCount: 30, now: Date(timeIntervalSince1970: slotStart), lowThreshold: low, highThreshold: high) + for second in stride(from: 0.0, to: BleDensityPolicy.skipSlot, by: 1.0) { + XCTAssertEqual(first, BleDensityPolicy.shouldSkip(id: "51:AC:A5:39:6C:00", meshPeerCount: 30, now: Date(timeIntervalSince1970: slotStart + second), lowThreshold: low, highThreshold: high)) + } + } + + func testTheBucketMatchesAndroid() { + // BleDensityPolicyTest.kt pins the same values. The two hashes are + // written by hand, and this keeps them to the same bits. It is not a + // runtime agreement: Android keys on the peer's MAC and iOS on its own + // CBPeripheral identifier, never the same string. + XCTAssertEqual(BleDensityPolicy.bucket(id: "51:AC:A5:39:6C:00", slot: 7), 0.133, accuracy: 1e-9) + XCTAssertEqual(BleDensityPolicy.bucket(id: "51:AC:A5:39:6C:00", slot: 0), 0.314, accuracy: 1e-9) + } +} diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 64a2b8c9..83f2f6b0 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -14074,11 +14074,11 @@ mod tests { // A sighting recorded after a filter inherits that filter's semantics, // and neither filter below the `shouldProcess` gate is about whether // the peripheral was observed: both are load shedding, dropping work - // rather than observations. `shouldProbabilisticallySkip` keys on - // `peripheral.hashValue`, which Swift seeds once per process, so a - // sighting recorded after it would miss a FIXED subset of the visible - // peers for the whole life of the process, while their neighbours moved - // the cutoff forward every second. Those peers read as "went quiet + // rather than observations. `shouldProbabilisticallySkip` passes over + // up to 80% of the visible peers for a minute at a time (before + // BleDensityPolicy it was a FIXED subset for the life of the process), + // so a sighting recorded after it would miss those peers while their + // neighbours moved the cutoff forward every second. Those peers read as "went quiet // while we were watching" at the next restoration and lose the pending // connect that is the only way iOS wakes this app when they reappear, // although they were advertising throughout. Same class as anchoring to @@ -14099,7 +14099,7 @@ mod tests { ); for filter in [ "if shouldFilterByRssi(rssiValue) {", - "if shouldProbabilisticallySkip(peripheral.identifier) {", + "if shouldProbabilisticallySkip(peripheral.identifier, now: now) {", ] { let filter_at = discover_body .find(filter) @@ -14108,8 +14108,8 @@ mod tests { sighting_at < filter_at, "ORDERING INVARIANT: the `.advertisement` sighting must be recorded BEFORE \ `{filter}`. That filter sheds load, it does not decide whether the peripheral \ - was seen, and the probabilistic one is keyed on a per-process hash seed — so \ - recording after it silently hides a fixed subset of peers that were \ + was seen, and the probabilistic one passes over up to 80% of the peers for a \ + minute at a time, so recording after it silently hides peers that were \ advertising the whole time and cancels their pending connects at the next \ restoration" ); @@ -14263,6 +14263,95 @@ mod tests { ); } + /// Android hears Bluetooth go off and come back from the adapter's state + /// broadcast, not only from a failed scan start. Polled once a minute, the + /// transport missed a Bluetooth stack crash entirely (the stack restarts + /// in under a second and takes the GATT server, advertiser and pending + /// connects with it), and the other phone could not reach this one until + /// the app restarted. `BleTransportFacade` has no test harness, so this + /// pins the wiring: registered once the transport runs, unregistered + /// behind the shutdown barrier, and the radio-lost arm drops the links. + #[test] + fn react_native_android_ble_listens_for_bluetooth_state_changes() { + let kotlin = rn_source_code_only( + "android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt", + ); + let start = kotlin + .find("private fun startUnsafe()") + .expect("BleTransportFacade.kt must start in startUnsafe"); + let start_body = &kotlin[start..]; + let running = start_body + .find("updateState(TransportState.RUNNING)") + .expect("startUnsafe must reach RUNNING"); + let register = start_body + .find("registerAdapterStateReceiver()") + .expect("startUnsafe must listen for Bluetooth state changes"); + assert!( + running < register, + "register once the transport runs, so a broadcast never meets a half-started one" + ); + + let stop = kotlin + .find("private fun stopUnsafe()") + .expect("BleTransportFacade.kt must stop in stopUnsafe"); + let stop_body = &kotlin[stop..]; + let barrier = stop_body + .find("shuttingDown = true") + .expect("stopUnsafe must raise the shutdown barrier"); + let unregister = stop_body + .find("unregisterAdapterStateReceiver()") + .expect("stopUnsafe must stop listening for Bluetooth state changes"); + assert!(barrier < unregister); + + let handler = kotlin + .find("private fun onAdapterStateChanged(") + .expect("the receiver must hand off to onAdapterStateChanged"); + let lost = kotlin[handler..] + .find("AdapterStateTransition.RADIO_LOST ->") + .expect("a radio loss must be handled"); + let drop = kotlin[handler..] + .find("dropLinksAfterRadioLoss()") + .expect("a radio loss must drop the dead links"); + assert!(lost < drop); + + // The receiver outlives a pause. Rebuilding from it restarted the scan + // and GATT server and reported BLE available to a core the module had + // paused in the same step, so both arms defer to resume(). + let handler_body = &kotlin[handler..]; + // The source is read trimmed, so a body ends at the next declaration. + let handler_end = handler_body[1..] + .find("private fun ") + .map_or(handler_body.len(), |i| i + 1); + let handler_body = &handler_body[..handler_end]; + assert!( + handler_body.contains("state != TransportState.RUNNING) return"), + "onAdapterStateChanged must ignore a transport that is not running" + ); + assert!( + handler_body.contains("if (!paused) scheduleBleRecovery()"), + "a radio loss while paused must not arm the recovery that restarts the transport" + ); + assert!( + handler_body.contains("if (paused) {"), + "Bluetooth back while paused must leave the rebuild to resume()" + ); + for (body, latch) in [ + ("private fun pauseUnsafe()", "paused = true"), + ("private fun resumeUnsafe()", "paused = false"), + ] { + let at = kotlin + .find(body) + .unwrap_or_else(|| panic!("{body} must exist")); + let end = kotlin[at + 1..] + .find("private fun ") + .map_or(kotlin.len() - at, |i| i + 1); + assert!( + kotlin[at..at + end].contains(latch), + "{body} must set `{latch}`" + ); + } + } + /// The buffered-inbound event set agrees across TypeScript, Kotlin and /// Swift, and each layer's hold is wired to a flush. ///