diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 0e4f29af..69f15294 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -101,6 +101,10 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + fun noteVerifiedAnnounceReceived(deviceAddress: String) { + connectionTracker.noteAnnounceReceived(deviceAddress) + } + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 94feb68d..f67cc918 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -30,6 +30,9 @@ class BluetoothConnectionTracker( val addressPeerMap = ConcurrentHashMap() // Track whether we have seen the first ANNOUNCE on a given device connection private val firstAnnounceSeen = ConcurrentHashMap() + // CCCD writes that arrived before a verified ANNOUNCE. Grant the feed later + // rather than rejecting the descriptor — Android clients do not retry it. + private val pendingBroadcastSubscriptions = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() private val connectionStateLock = Any() @@ -85,6 +88,7 @@ class BluetoothConnectionTracker( removePendingConnection(deviceAddress) // Mark as awaiting first ANNOUNCE on this connection firstAnnounceSeen[deviceAddress] = false + pendingBroadcastSubscriptions.remove(deviceAddress) } /** @@ -276,6 +280,7 @@ class BluetoothConnectionTracker( subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) firstAnnounceSeen.remove(deviceAddress) + pendingBroadcastSubscriptions.remove(deviceAddress) } Log.d(TAG, "Cleaned up device connection for $deviceAddress") } @@ -292,6 +297,7 @@ class BluetoothConnectionTracker( subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) firstAnnounceSeen.remove(deviceAddress) + pendingBroadcastSubscriptions.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") true } else { @@ -330,13 +336,47 @@ class BluetoothConnectionTracker( pendingConnections.clear() scanRSSI.clear() firstAnnounceSeen.clear() + pendingBroadcastSubscriptions.clear() } /** - * Mark that we have received the first ANNOUNCE over this device connection. + * Record a CCCD enable. Grants the broadcast feed immediately when a verified + * ANNOUNCE already arrived on this connection; otherwise defers until it does. + * + * @return true if the feed was granted now + */ + fun requestBroadcastSubscription(device: BluetoothDevice): Boolean { + synchronized(connectionStateLock) { + val action = GattNotificationEligibility.action( + firstAnnounceSeen[device.address] == true + ) + if (action == GattSubscriptionAction.GRANT) { + pendingBroadcastSubscriptions.remove(device.address) + addSubscribedDeviceIfAbsent(device) + return true + } + pendingBroadcastSubscriptions[device.address] = device + return false + } + } + + /** + * Mark that we have received the first ANNOUNCE over this device connection + * and grant any CCCD write that was waiting on it. */ fun noteAnnounceReceived(deviceAddress: String) { - firstAnnounceSeen[deviceAddress] = true + synchronized(connectionStateLock) { + firstAnnounceSeen[deviceAddress] = true + pendingBroadcastSubscriptions.remove(deviceAddress)?.let { device -> + addSubscribedDeviceIfAbsent(device) + } + } + } + + private fun addSubscribedDeviceIfAbsent(device: BluetoothDevice) { + if (subscribedDevices.none { it.address == device.address }) { + subscribedDevices.add(device) + } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 038cd960..7d69a257 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -283,7 +283,13 @@ class BluetoothGattServerManager( } if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) { - connectionTracker.addSubscribedDevice(device) + val granted = connectionTracker.requestBroadcastSubscription(device) + if (!granted) { + Log.i( + TAG, + "Deferring broadcast feed for ${device.address} until verified ANNOUNCE" + ) + } connectionScope.launch { delay(100) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 22f17867..2b9ab5ab 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -579,6 +579,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val result = messageHandler.handleAnnounceWithResult(routed) if (result !is AnnounceHandlingResult.Accepted) return false + routed.relayAddress?.let { deviceAddress -> + connectionManager.noteVerifiedAnnounceReceived(deviceAddress) + } + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation -> if (connectionManager.observePeerIfCurrent( observation.relayAddress, diff --git a/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt b/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt new file mode 100644 index 00000000..24327d4a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/GattNotificationEligibility.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.mesh + +/** + * Whether a BLE central may receive the mesh broadcast notification feed. + * + * Granting the feed before the peer has sent a verified ANNOUNCE would let a + * passive listener harvest it with no participation (#901). Android clients + * write the CCCD during service discovery, before they send that ANNOUNCE, and + * they do not retry the descriptor write — so a GATT reject here would leave a + * legitimate peer unsubscribed even after they announce. Defer instead: accept + * the CCCD write, withhold packets, then grant once the ANNOUNCE verifies. + */ +internal enum class GattSubscriptionAction { + GRANT, + DEFER, +} + +internal object GattNotificationEligibility { + fun action(hasVerifiedAnnounce: Boolean): GattSubscriptionAction = + if (hasVerifiedAnnounce) GattSubscriptionAction.GRANT else GattSubscriptionAction.DEFER +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt index bde80cdc..928262a2 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt @@ -79,6 +79,58 @@ class BluetoothConnectionTrackerLinkObservationTest { assertTrue(tracker.addressPeerMap.containsValue(PEER_ID)) } + @Test + fun `cccd before announce withholds the feed then grants it`() { + val address = "AA:BB:CC:DD:EE:FF" + val device = mock() + whenever(device.address).thenReturn(address) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a") + ) + + assertFalse(tracker.requestBroadcastSubscription(device)) + assertTrue(tracker.getSubscribedDevices().none { it.address == address }) + + tracker.noteAnnounceReceived(address) + assertTrue(tracker.getSubscribedDevices().any { it.address == address }) + } + + @Test + fun `announce before cccd grants the feed on the descriptor write`() { + val address = "AA:BB:CC:DD:EE:00" + val device = mock() + whenever(device.address).thenReturn(address) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b") + ) + + tracker.noteAnnounceReceived(address) + assertTrue(tracker.requestBroadcastSubscription(device)) + assertTrue(tracker.getSubscribedDevices().any { it.address == address }) + } + + @Test + fun `disconnect drops a deferred broadcast subscription`() { + val address = "AA:BB:CC:DD:EE:01" + val device = mock() + whenever(device.address).thenReturn(address) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-c") + ) + tracker.requestBroadcastSubscription(device) + tracker.cleanupDeviceConnection(address) + + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-d") + ) + tracker.noteAnnounceReceived(address) + assertTrue(tracker.getSubscribedDevices().none { it.address == address }) + } + private companion object { const val PEER_ID = "0011223344556677" } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt new file mode 100644 index 00000000..61c05054 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/GattNotificationEligibilityTest.kt @@ -0,0 +1,22 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GattNotificationEligibilityTest { + @Test + fun `subscription is deferred before a verified announce`() { + assertEquals( + GattSubscriptionAction.DEFER, + GattNotificationEligibility.action(hasVerifiedAnnounce = false) + ) + } + + @Test + fun `subscription is granted after a verified announce`() { + assertEquals( + GattSubscriptionAction.GRANT, + GattNotificationEligibility.action(hasVerifiedAnnounce = true) + ) + } +}