Merge 0eca9daae43ebd92b769c934eccfa504e7e16faf into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Taksh Kothari 2026-09-14 09:43:25 +05:30 committed by GitHub
commit e25cf851d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 152 additions and 3 deletions

View File

@ -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)

View File

@ -30,6 +30,9 @@ class BluetoothConnectionTracker(
val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// 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<String, BluetoothDevice>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
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)
}
}
/**

View File

@ -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)

View File

@ -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,

View File

@ -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
}

View File

@ -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<BluetoothDevice>()
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<BluetoothDevice>()
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<BluetoothDevice>()
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"
}

View File

@ -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)
)
}
}