drop unused connections

This commit is contained in:
callebtc
2025-09-05 23:28:58 +02:00
parent 4b81b7f97a
commit c1d74bd86d
5 changed files with 249 additions and 1 deletions
@@ -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<Triple<String, String, Long>> {
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() {
@@ -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<String, ConnectionAttempt>()
// Track consecutive failures per device
private val failureCounts = ConcurrentHashMap<String, Int>()
// Devices to avoid reconnecting to (blacklist)
private val avoidedDevices = ConcurrentHashMap<String, AvoidInfo>()
// Announce watchdog jobs per device address
private val announceWatchdogs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
// 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<String, AvoidInfo> {
return avoidedDevices.toMap()
}
}
@@ -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)
@@ -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 {
@@ -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<Triple<String, String, Long>>()) }
// 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)) {