Implement iOS-compatible Direct Peer Detection (TTL Logic) (#574)

* Clean up direct peer detection logic (Max TTL) and remove unrelated routing/media changes

* cleanup fix
This commit is contained in:
callebtc
2026-01-15 15:24:24 +07:00
committed by GitHub
parent 8358073420
commit 83fbcca557
5 changed files with 49 additions and 59 deletions
@@ -85,10 +85,6 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping // Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap val addressPeerMap get() = connectionTracker.addressPeerMap
// Expose first-announce helpers to higher layers
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
init { init {
powerManager.delegate = this powerManager.delegate = this
// Observe debug settings to enforce role state while active // Observe debug settings to enforce role state while active
@@ -30,9 +30,6 @@ class BluetoothConnectionTracker(
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>() private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>() private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>() val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers) // RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>() private val scanRSSI = ConcurrentHashMap<String, Int>()
@@ -94,8 +91,6 @@ class BluetoothConnectionTracker(
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn connectedDevices[deviceAddress] = deviceConn
pendingConnections.remove(deviceAddress) pendingConnections.remove(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
} }
/** /**
@@ -332,7 +327,6 @@ class BluetoothConnectionTracker(
subscribedDevices.removeAll { it.address == deviceAddress } subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress) addressPeerMap.remove(deviceAddress)
} }
firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress") Log.d(TAG, "Cleaned up device connection for $deviceAddress")
} }
@@ -366,23 +360,8 @@ class BluetoothConnectionTracker(
addressPeerMap.clear() addressPeerMap.clear()
pendingConnections.clear() pendingConnections.clear()
scanRSSI.clear() scanRSSI.clear()
firstAnnounceSeen.clear()
} }
/**
* Mark that we have received the first ANNOUNCE over this device connection.
*/
fun noteAnnounceReceived(deviceAddress: String) {
firstAnnounceSeen[deviceAddress] = true
}
/**
* Check whether the first ANNOUNCE has been seen for a device connection.
*/
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
return firstAnnounceSeen[deviceAddress] == true
}
/** /**
* Start periodic cleanup of expired connections * Start periodic cleanup of expired connections
*/ */
@@ -111,6 +111,13 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet) return signPacketBeforeBroadcast(packet)
} }
} }
// Inject dynamic direct connection check into PeerManager
// Matches iOS logic: checks if we have an active hardware mapping for this peer
peerManager.isPeerDirectlyConnected = { peerID ->
connectionManager.addressPeerMap.containsValue(peerID)
}
Log.d(TAG, "Delegates set up; GossipSyncManager initialized") Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
} }
@@ -469,21 +476,24 @@ class BluetoothMeshService(private val context: Context) {
// Process the announce // Process the announce
val isFirst = messageHandler.handleAnnounce(routed) val isFirst = messageHandler.handleAnnounce(routed)
// Map device address -> peerID on first announce seen over this device connection // Map device address -> peerID based on TTL (max TTL = direct neighbor)
// Matches iOS logic: any announce with max TTL on a link defines the direct peer
val deviceAddress = routed.relayAddress val deviceAddress = routed.relayAddress
val pid = routed.peerID val pid = routed.peerID
if (deviceAddress != null && pid != null) { if (deviceAddress != null && pid != null) {
// First ANNOUNCE over a device connection defines a direct neighbor. // Check if this is a direct connection (MAX TTL)
if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) { // Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (isDirect) {
// Bind or rebind this device address to the announcing peer // Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid connectionManager.addressPeerMap[deviceAddress] = pid
connectionManager.noteAnnounceReceived(deviceAddress) Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
// Mark as directly connected (upgrades from routed if needed) // Mark as directly connected - refresh UI state
try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { } try { peerManager.refreshPeerList() } catch (_: Exception) { }
// Initial sync for this newly direct peer // Initial sync for this direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
} }
} }
@@ -583,12 +593,11 @@ class BluetoothMeshService(private val context: Context) {
val peer = connectionManager.addressPeerMap[addr] val peer = connectionManager.addressPeerMap[addr]
// ConnectionTracker has already removed the address mapping; be defensive either way // ConnectionTracker has already removed the address mapping; be defensive either way
connectionManager.addressPeerMap.remove(addr) connectionManager.addressPeerMap.remove(addr)
// refresh peer list on disconnect.
try { peerManager.refreshPeerList() } catch (_: Exception) { }
if (peer != null) { if (peer != null) {
val stillMapped = connectionManager.addressPeerMap.values.any { it == peer }
if (!stillMapped) {
// Peer might still be reachable indirectly; mark as not-direct
try { peerManager.setDirectConnection(peer, false) } catch (_: Exception) { }
}
// Verbose debug: device disconnected // Verbose debug: device disconnected
try { try {
val nick = peerManager.getPeerNickname(peer) ?: "unknown" val nick = peerManager.getPeerNickname(peer) ?: "unknown"
@@ -86,6 +86,9 @@ class PeerManager {
// Delegate for callbacks // Delegate for callbacks
var delegate: PeerManagerDelegate? = null var delegate: PeerManagerDelegate? = null
// Callback to check if a peer is directly connected (injected by BluetoothMeshService)
var isPeerDirectlyConnected: ((String) -> Boolean)? = null
// Coroutines // Coroutines
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -145,10 +148,18 @@ class PeerManager {
} }
/** /**
* Get peer info * Get peer info with dynamic direct connection status
*/ */
fun getPeerInfo(peerID: String): PeerInfo? { fun getPeerInfo(peerID: String): PeerInfo? {
return peers[peerID] return peers[peerID]?.let { info ->
// Dynamically check direct connection status from ConnectionManager
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
if (info.isDirectConnection != isDirect) {
info.copy(isDirectConnection = isDirect)
} else {
info
}
}
} }
/** /**
@@ -159,28 +170,21 @@ class PeerManager {
} }
/** /**
* Get all verified peers * Get all verified peers with dynamic direct connection status
*/ */
fun getVerifiedPeers(): Map<String, PeerInfo> { fun getVerifiedPeers(): Map<String, PeerInfo> {
return peers.filterValues { it.isVerifiedNickname } return peers.filterValues { it.isVerifiedNickname }.mapValues { (_, info) ->
val isDirect = isPeerDirectlyConnected?.invoke(info.id) ?: false
if (info.isDirectConnection != isDirect) info.copy(isDirectConnection = isDirect) else info
}
} }
/** /**
* Set whether a peer is directly connected over Bluetooth. * Force a peer list update notification.
* Triggers a peer list update to refresh UI badges. * Call this when connection state changes to refresh UI badges.
*/ */
fun setDirectConnection(peerID: String, isDirect: Boolean) { fun refreshPeerList() {
peers[peerID]?.let { existing -> notifyPeerListUpdate()
if (existing.isDirectConnection != isDirect) {
peers[peerID] = existing.copy(isDirectConnection = isDirect)
notifyPeerListUpdate()
// NEW: notify UI state (if available via delegate path) about directness change
try {
// Best-effort: delegate path flows up to ChatViewModel via didUpdatePeerList
// No direct reference to UI layer here by design.
} catch (_: Exception) { }
}
}
} }
// MARK: - Legacy Methods (maintained for compatibility) // MARK: - Legacy Methods (maintained for compatibility)
@@ -364,7 +368,11 @@ class PeerManager {
return buildString { return buildString {
appendLine("=== Peer Manager Debug Info ===") appendLine("=== Peer Manager Debug Info ===")
appendLine("Active Peers: ${activeIds.size}") appendLine("Active Peers: ${activeIds.size}")
peers.forEach { (peerID, info) -> peers.forEach { (peerID, storedInfo) ->
// Use dynamic direct status for debug log accuracy
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
val info = if (storedInfo.isDirectConnection != isDirect) storedInfo.copy(isDirectConnection = isDirect) else storedInfo
val timeSince = (now - info.lastSeen) / 1000 val timeSince = (now - info.lastSeen) / 1000
val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI" val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI"
val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key
-2
View File
@@ -38,7 +38,6 @@ Timers:
- Added a `DeviceMonitoringManager` instance and provided a `disconnectCallback` that: - Added a `DeviceMonitoringManager` instance and provided a `disconnectCallback` that:
- disconnects client GATT connections via `BluetoothConnectionTracker`. - disconnects client GATT connections via `BluetoothConnectionTracker`.
- cancels server connections via `BluetoothGattServer.cancelConnection`. - cancels server connections via `BluetoothGattServer.cancelConnection`.
- Exposed `noteAnnounceReceived(address)` as a small helper for higher layers.
- Updated `componentDelegate.onPacketReceived` to notify per-device activity to the monitor. - Updated `componentDelegate.onPacketReceived` to notify per-device activity to the monitor.
2) GATT Client 2) GATT Client
@@ -59,7 +58,6 @@ Timers:
4) ANNOUNCE Binding 4) ANNOUNCE Binding
- File: `BluetoothMeshService.kt` (in the ANNOUNCE handler where we first map device → peer) - File: `BluetoothMeshService.kt` (in the ANNOUNCE handler where we first map device → peer)
- After mapping a device address to a peer on first verified ANNOUNCE, call `connectionManager.noteAnnounceReceived(address)` to cancel the 15s timer for that device.
## Behavior Summary ## Behavior Summary