From c1d74bd86db8778837c15e1c99faba52b00a765a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 5 Sep 2025 23:28:58 +0200 Subject: [PATCH] drop unused connections --- .../mesh/BluetoothConnectionManager.kt | 54 +++++++ .../mesh/BluetoothConnectionTracker.kt | 133 +++++++++++++++++- .../mesh/BluetoothGattClientManager.kt | 2 + .../android/mesh/BluetoothMeshService.kt | 18 +++ .../android/ui/debug/DebugSettingsSheet.kt | 43 ++++++ 5 files changed, 249 insertions(+), 1 deletion(-) 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 0cc7f967..b5d58f09 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -57,6 +57,8 @@ class BluetoothConnectionManager( } override fun onDeviceConnected(device: BluetoothDevice) { + // Start ANNOUNCE watchdog generically for both client/server connections + try { connectionTracker.startAnnounceWatchdog(device.address) } catch (_: Exception) { } delegate?.onDeviceConnected(device) } @@ -87,6 +89,8 @@ class BluetoothConnectionManager( init { powerManager.delegate = this + // When tracker avoids a device, actively drop its connection (client or server) + try { connectionTracker.setOnDeviceAvoided { addr, _ -> forceDisconnectAddress(addr, null) } } catch (_: Exception) { } // Observe debug settings to enforce role state while active try { val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() @@ -270,6 +274,26 @@ class BluetoothConnectionManager( } } + // Debug: avoided devices snapshot (address, reason, since) + fun getAvoidedDevices(): List> { + return try { + val snap = connectionTracker.getAvoidedDevicesSnapshot() + snap.map { (addr, info) -> Triple(addr, info.reason, info.since) } + } catch (e: Exception) { + emptyList() + } + } + + // Debug: remove one entry from avoid list + fun removeFromAvoidList(address: String) { + try { connectionTracker.removeFromAvoidList(address) } catch (_: Exception) { } + } + + // Debug: clear the entire avoid list + fun clearAvoidList() { + try { connectionTracker.clearAvoidList() } catch (_: Exception) { } + } + // Expose local adapter address for debug UI fun getLocalAdapterAddress(): String? = try { bluetoothAdapter?.address } catch (e: Exception) { null } @@ -283,6 +307,36 @@ class BluetoothConnectionManager( fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address) fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) } + // Disconnect a device whether it is a client or server connection; optionally add to avoid list + fun forceDisconnectAddress(address: String, blacklistReason: String? = null) { + try { + // If client-side GATT exists, disconnect via GATT + val dc = connectionTracker.getConnectedDevices()[address] + if (dc?.gatt != null) { + try { dc.gatt.disconnect() } catch (_: Exception) { } + } + // If it's a server-side subscribed device, cancel via GATT server + val server = serverManager.getGattServer() + if (server != null) { + val sub = connectionTracker.getSubscribedDevices().firstOrNull { it.address == address } + if (sub != null) { + try { server.cancelConnection(sub) } catch (_: Exception) { } + } + } + } finally { + if (blacklistReason != null) { + try { connectionTracker.addToAvoidList(address, blacklistReason) } catch (_: Exception) { } + } else { + connectionTracker.cleanupDeviceConnection(address) + } + } + } + + // Helper for Mesh to acknowledge announce mapping and cancel watchdog + fun markAnnounceReceived(deviceAddress: String, peerID: String) { + try { connectionTracker.markAnnounceReceived(deviceAddress, peerID) } catch (_: Exception) { } + } + // Optionally disconnect all connections (server and client) fun disconnectAll() { 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 3494d1b2..74dd6ea4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -22,6 +22,7 @@ class BluetoothConnectionTracker( private const val TAG = "BluetoothConnectionTracker" private const val CONNECTION_RETRY_DELAY = 5000L private const val MAX_CONNECTION_ATTEMPTS = 3 + private const val DEFAULT_ANNOUNCE_TIMEOUT_MS = 5000L private const val CLEANUP_DELAY = 500L private const val CLEANUP_INTERVAL = 30000L // 30 seconds } @@ -36,9 +37,19 @@ class BluetoothConnectionTracker( // Connection attempt tracking with automatic cleanup private val pendingConnections = ConcurrentHashMap() + // Track consecutive failures per device + private val failureCounts = ConcurrentHashMap() + // Devices to avoid reconnecting to (blacklist) + private val avoidedDevices = ConcurrentHashMap() + // Announce watchdog jobs per device address + private val announceWatchdogs = ConcurrentHashMap() + // Configurable ANNOUNCE timeout (ms) + @Volatile private var announceTimeoutMs: Long = DEFAULT_ANNOUNCE_TIMEOUT_MS // State management private var isActive = false + // Optional callback to actively drop connections when a device is avoided + @Volatile private var onDeviceAvoided: ((address: String, reason: String) -> Unit)? = null /** * Consolidated device connection information @@ -66,6 +77,14 @@ class BluetoothConnectionTracker( attempts < MAX_CONNECTION_ATTEMPTS && System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY } + + /** + * Metadata for avoided devices + */ + data class AvoidInfo( + val reason: String, + val since: Long = System.currentTimeMillis() + ) /** * Start the connection tracker @@ -91,6 +110,9 @@ class BluetoothConnectionTracker( Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") connectedDevices[deviceAddress] = deviceConn pendingConnections.remove(deviceAddress) + // Successful connection -> reset failure count and cancel any blacklist for this address + failureCounts.remove(deviceAddress) + // Do not auto-remove from avoidedDevices to keep defensive policy; only manual removal } /** @@ -171,6 +193,11 @@ class BluetoothConnectionTracker( * Check if connection attempt is allowed */ fun isConnectionAttemptAllowed(deviceAddress: String): Boolean { + // Never attempt to connect to avoided devices + if (avoidedDevices.containsKey(deviceAddress)) { + Log.d(TAG, "Tracker: Device $deviceAddress is in avoid list; skipping attempts") + return false + } val existingAttempt = pendingConnections[deviceAddress] return existingAttempt?.let { it.isExpired() || it.shouldRetry() @@ -218,6 +245,89 @@ class BluetoothConnectionTracker( fun removePendingConnection(deviceAddress: String) { pendingConnections.remove(deviceAddress) } + + /** + * Record a connection failure. If failures exceed threshold, avoid device. + */ + fun recordConnectionFailure(deviceAddress: String, reason: String? = null) { + val newCount = (failureCounts[deviceAddress] ?: 0) + 1 + failureCounts[deviceAddress] = newCount + Log.w(TAG, "Tracker: Failure #$newCount for $deviceAddress${reason?.let { ": $it" } ?: ""}") + if (newCount >= MAX_CONNECTION_ATTEMPTS) { + addToAvoidList(deviceAddress, reason ?: "too_many_failures") + } + } + + /** + * Add device to avoid list and cleanup its connection state. + */ + fun addToAvoidList(deviceAddress: String, reason: String) { + Log.w(TAG, "Tracker: Adding $deviceAddress to avoid list (reason: $reason)") + avoidedDevices[deviceAddress] = AvoidInfo(reason) + // Actively drop connection via callback if provided + try { onDeviceAvoided?.invoke(deviceAddress, reason) } catch (_: Exception) { } + // Ensure connection state is removed afterwards + cleanupDeviceConnection(deviceAddress) + } + + /** + * Set the ANNOUNCE timeout in milliseconds (configurable). + */ + fun setAnnounceTimeout(timeoutMs: Long) { + announceTimeoutMs = timeoutMs.coerceAtLeast(1000L) + } + + /** Set a callback invoked when a device is added to avoid list. */ + fun setOnDeviceAvoided(callback: (address: String, reason: String) -> Unit) { + onDeviceAvoided = callback + } + + /** + * Start a watchdog that drops the connection if ANNOUNCE not mapped for this address. + */ + fun startAnnounceWatchdog(deviceAddress: String) { + // Cancel any existing watchdog first + cancelAnnounceWatchdog(deviceAddress) + val job = connectionScope.launch { + val startedAt = System.currentTimeMillis() + delay(announceTimeoutMs) + // If no ANNOUNCE mapping exists and device still connected, drop and avoid + val mapped = addressPeerMap.containsKey(deviceAddress) + val stillConnected = connectedDevices.containsKey(deviceAddress) || + subscribedDevices.any { it.address == deviceAddress } + if (!mapped && stillConnected) { + Log.w(TAG, "Tracker: ANNOUNCE not received within ${announceTimeoutMs}ms for $deviceAddress; dropping + avoiding") + addToAvoidList(deviceAddress, "announce_timeout") + } else { + Log.d(TAG, "Tracker: ANNOUNCE watchdog passed for $deviceAddress in ${System.currentTimeMillis() - startedAt}ms (mapped=$mapped, connected=$stillConnected)") + } + } + announceWatchdogs[deviceAddress] = job + } + + /** Cancel ANNOUNCE watchdog for an address. */ + fun cancelAnnounceWatchdog(deviceAddress: String) { + announceWatchdogs.remove(deviceAddress)?.cancel() + } + + /** Notify tracker that ANNOUNCE was received/mapped for this device. */ + fun markAnnounceReceived(deviceAddress: String, peerID: String) { + // Map is updated externally; we just cancel watchdog here + cancelAnnounceWatchdog(deviceAddress) + Log.d(TAG, "Tracker: ANNOUNCE mapped for $deviceAddress -> $peerID; watchdog canceled") + } + + /** Remove a single device from the avoid list (allow future attempts). */ + fun removeFromAvoidList(deviceAddress: String) { + avoidedDevices.remove(deviceAddress) + Log.d(TAG, "Tracker: Removed $deviceAddress from avoid list") + } + + /** Clear the entire avoid list (dangerous; for debug UI). */ + fun clearAvoidList() { + avoidedDevices.clear() + Log.d(TAG, "Tracker: Cleared avoid list") + } /** * Get connected device count @@ -280,6 +390,7 @@ class BluetoothConnectionTracker( addressPeerMap.remove(deviceAddress) } pendingConnections.remove(deviceAddress) + cancelAnnounceWatchdog(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") } @@ -313,6 +424,10 @@ class BluetoothConnectionTracker( addressPeerMap.clear() pendingConnections.clear() scanRSSI.clear() + failureCounts.clear() + avoidedDevices.clear() + announceWatchdogs.values.forEach { it.cancel() } + announceWatchdogs.clear() } /** @@ -337,7 +452,7 @@ class BluetoothConnectionTracker( // Log current state Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending") - + } catch (e: Exception) { Log.w(TAG, "Error in periodic cleanup: ${e.message}") } @@ -365,10 +480,26 @@ class BluetoothConnectionTracker( appendLine(" - $address: ${attempt.attempts} attempts, last ${elapsed}s ago") } appendLine() + appendLine("Failures: ${failureCounts.size}") + failureCounts.forEach { (address, count) -> + appendLine(" - $address: $count failures") + } + appendLine() + appendLine("Avoid List: ${avoidedDevices.size}") + avoidedDevices.forEach { (address, info) -> + val age = (now - info.since) / 1000 + appendLine(" - $address: ${info.reason} (${age}s)") + } + appendLine() appendLine("Scan RSSI Cache: ${scanRSSI.size}") scanRSSI.forEach { (address, rssi) -> appendLine(" - $address: $rssi dBm") } } } + + /** Snapshot of avoided devices for debug UI */ + fun getAvoidedDevicesSnapshot(): Map { + return avoidedDevices.toMap() + } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index bd36c8ce..104bc02c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -410,6 +410,8 @@ class BluetoothGattClientManager( if (status == 147) { Log.e(TAG, "Client: Connection establishment failed (status 147) for $deviceAddress") } + // Record failure for retry policy / blacklist + connectionTracker.recordConnectionFailure(deviceAddress, "gatt_status_$status") } else { Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress") connectionTracker.cleanupDeviceConnection(deviceAddress) 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 da3d1226..68f7c043 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -359,8 +359,26 @@ class BluetoothMeshService(private val context: Context) { // Only set mapping if not already mapped if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) { connectionManager.addressPeerMap[deviceAddress] = pid + // Cancel watchdog and mark announce received + connectionManager.markAnnounceReceived(deviceAddress, pid) Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE") + // If we already have connections for this peer on other devices, drop the older ones + try { + val duplicates = connectionManager.addressPeerMap + .filter { (addr, mappedPid) -> mappedPid == pid && addr != deviceAddress } + .map { it.key } + if (duplicates.isNotEmpty()) { + Log.w(TAG, "Duplicate connections for peer $pid on devices: $duplicates — dropping old ones") + duplicates.forEach { otherAddr -> + // Blacklist the old devices to avoid reconnect storm + connectionManager.forceDisconnectAddress(otherAddr, "duplicate_peer_$pid") + // Remove mapping explicitly + connectionManager.addressPeerMap.remove(otherAddr) + } + } + } catch (_: Exception) { } + // Mark this peer as directly connected for UI try { peerManager.getPeerInfo(pid)?.let { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 33fde064..8f666663 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -48,6 +48,7 @@ fun DebugSettingsSheet( val scanResults by manager.scanResults.collectAsState() val connectedDevices by manager.connectedDevices.collectAsState() val relayStats by manager.relayStats.collectAsState() + var avoided by remember { mutableStateOf(listOf>()) } // Push live connected devices from mesh service whenever sheet is visible LaunchedEffect(isPresented) { @@ -71,6 +72,7 @@ fun DebugSettingsSheet( ) } manager.updateConnectedDevices(devices) + avoided = meshService.connectionManager.getAvoidedDevices() kotlinx.coroutines.delay(1000) } } @@ -313,6 +315,47 @@ fun DebugSettingsSheet( } } + // Avoid list (under scan results) + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Cancel, contentDescription = null, tint = Color(0xFFBF1A1A)) + Text("avoid list", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Text( + "clear", + color = Color(0xFFBF1A1A), + fontFamily = FontFamily.Monospace, + modifier = Modifier.clickable { meshService.connectionManager.clearAvoidList() } + ) + } + if (avoided.isEmpty()) { + Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + val now = System.currentTimeMillis() + avoided.sortedByDescending { it.third }.forEach { (addr, reason, since) -> + val ageSec = ((now - since) / 1000).coerceAtLeast(0) + Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { + Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(addr, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("reason: $reason • ${ageSec}s", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + Text( + "remove", + color = Color(0xFF007AFF), + fontFamily = FontFamily.Monospace, + modifier = Modifier.clickable { meshService.connectionManager.removeFromAvoidList(addr) } + ) + } + } + } + } + } + } + } + // Debug console item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {