From e2387ac4a6b922c1760c372fbbc8d58d704c5c88 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:50:27 +0200 Subject: [PATCH] Optimize Wear mesh power usage --- .../bitchat/android/mesh/BleRuntimePolicy.kt | 93 ++++++++ .../mesh/BluetoothConnectionManager.kt | 117 ++++++++-- .../mesh/BluetoothConnectionTracker.kt | 62 +++++- .../mesh/BluetoothGattClientManager.kt | 199 ++++++++++++++---- .../mesh/BluetoothGattServerManager.kt | 132 +++++++++++- .../bitchat/android/mesh/FragmentManager.kt | 11 +- .../android/mesh/MeshConnectionTracker.kt | 26 ++- .../com/bitchat/android/mesh/PeerManager.kt | 14 +- .../bitchat/android/mesh/SecurityManager.kt | 15 +- .../android/mesh/StoreForwardManager.kt | 22 +- .../bitchat/android/sync/GossipSyncManager.kt | 40 ++-- .../mesh/BluetoothConnectionAdmissionTest.kt | 103 +++++++++ docs/release-gate-runbook.md | 9 +- tools/release_gate/mesh_lab.py | 72 +++++++ .../watch/testhook/WearTestHookDriver.kt | 20 ++ .../watch/mesh/WearBleRuntimePolicy.kt | 82 ++++++++ .../com/bitchat/watch/mesh/WearMeshService.kt | 64 ++---- .../java/com/bitchat/watch/ui/ChatScreen.kt | 10 +- .../java/com/bitchat/watch/ui/DmScreen.kt | 20 +- .../com/bitchat/watch/ui/PeerDebugScreen.kt | 6 +- .../java/com/bitchat/watch/ui/PeopleScreen.kt | 8 +- .../com/bitchat/watch/ui/UserDetailScreen.kt | 4 +- .../watch/ui/VerificationCodeScreen.kt | 4 +- .../bitchat/watch/ui/VoiceNoteController.kt | 20 +- .../com/bitchat/watch/ui/media/MediaItems.kt | 18 +- .../watch/mesh/WearBleRuntimePolicyTest.kt | 92 ++++++++ 26 files changed, 1086 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/BleRuntimePolicy.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionAdmissionTest.kt create mode 100644 wear/src/main/java/com/bitchat/watch/mesh/WearBleRuntimePolicy.kt create mode 100644 wear/src/test/java/com/bitchat/watch/mesh/WearBleRuntimePolicyTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BleRuntimePolicy.kt b/app/src/main/java/com/bitchat/android/mesh/BleRuntimePolicy.kt new file mode 100644 index 00000000..de573ee2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BleRuntimePolicy.kt @@ -0,0 +1,93 @@ +package com.bitchat.android.mesh + +/** + * Product-specific BLE policy injected into the shared transport. + * + * The phone keeps the historic behavior through [DefaultBleRuntimePolicy]. Wear can impose a + * stricter hard ceiling and a connection-aware discovery schedule without forking the transport. + */ +interface BleRuntimePolicy { + val collectDebugTelemetry: Boolean + + fun connectionLimits(requested: BleConnectionLimits): BleConnectionLimits + + fun scanPlan( + profile: PowerManager.RuntimePerformanceProfile, + activeConnections: Int + ): BleScanPlan + + fun shouldAdvertise( + profile: PowerManager.RuntimePerformanceProfile, + activeConnections: Int + ): Boolean + + fun shouldPollRssi(profile: PowerManager.RuntimePerformanceProfile): Boolean + + /** Return an Android BluetoothGatt connection-priority constant, or null to leave it alone. */ + fun gattConnectionPriority(profile: PowerManager.RuntimePerformanceProfile): Int? + + fun transferGattConnectionPriority(): Int? = null + + fun transferPriorityDurationMs(): Long = 10_000L +} + +data class BleConnectionLimits( + val overall: Int, + val server: Int, + val client: Int +) { + init { + require(overall >= 0) + require(server >= 0) + require(client >= 0) + } +} + +data class BleScanPlan( + val enabled: Boolean, + val scanOnMs: Long = 0L, + val scanOffMs: Long = 0L, + val continuous: Boolean = false +) + +object DefaultBleRuntimePolicy : BleRuntimePolicy { + override val collectDebugTelemetry: Boolean = true + + override fun connectionLimits(requested: BleConnectionLimits): BleConnectionLimits = requested + + override fun scanPlan( + profile: PowerManager.RuntimePerformanceProfile, + activeConnections: Int + ): BleScanPlan = BleScanPlan( + enabled = true, + scanOnMs = profile.ble.scanOnMs, + scanOffMs = profile.ble.scanOffMs, + continuous = profile.ble.continuousScan + ) + + override fun shouldAdvertise( + profile: PowerManager.RuntimePerformanceProfile, + activeConnections: Int + ): Boolean = true + + override fun shouldPollRssi(profile: PowerManager.RuntimePerformanceProfile): Boolean = true + + override fun gattConnectionPriority(profile: PowerManager.RuntimePerformanceProfile): Int? = null +} + +/** Lightweight counters exposed only through local debug hooks for power regression testing. */ +data class BlePowerSnapshot( + val activeConnections: Int, + val pendingConnections: Int, + val connectionLimit: Int, + val scanning: Boolean, + val scanStarts: Long, + val scanResults: Long, + val scanActiveMs: Long, + val advertising: Boolean, + val advertiseStarts: Long, + val advertiseActiveMs: Long, + val rssiReads: Long, + val background: Boolean, + val batteryBand: PowerManager.BatteryBand +) 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..24a46854 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -5,6 +5,7 @@ import android.content.Context import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine @@ -15,9 +16,10 @@ import kotlinx.coroutines.flow.combine * Coordinates smaller, focused components for better maintainability */ class BluetoothConnectionManager( - private val context: Context, + private val context: Context, private val myPeerID: String, - private val fragmentManager: FragmentManager? = null + private val fragmentManager: FragmentManager? = null, + private val runtimePolicy: BleRuntimePolicy = DefaultBleRuntimePolicy ) { companion object { @@ -37,7 +39,9 @@ class BluetoothConnectionManager( // Component managers private val permissionManager = BluetoothPermissionManager(context) - private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager) + private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager) { + connectionScope.launch { applyPowerPolicy() } + } private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager, myPeerID) // Delegate for component managers to call back to main manager @@ -64,11 +68,13 @@ class BluetoothConnectionManager( override fun onDeviceConnected(device: BluetoothDevice) { // Trigger limit enforcement immediately upon any new connection enforceStrictLimits() + applyPowerPolicy() delegate?.onDeviceConnected(device) } override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) { packetBroadcaster.onLinkDisconnected(device.address, linkID) + applyPowerPolicy() delegate?.onDeviceDisconnected(device, linkID, peerID) } @@ -86,10 +92,25 @@ class BluetoothConnectionManager( } private val serverManager = BluetoothGattServerManager( - context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID + context, + connectionScope, + connectionTracker, + permissionManager, + powerManager, + componentDelegate, + myPeerID, + runtimePolicy, + ::effectiveConnectionLimits ) private val clientManager = BluetoothGattClientManager( - context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate + context, + connectionScope, + connectionTracker, + permissionManager, + powerManager, + componentDelegate, + runtimePolicy, + ::effectiveConnectionLimits ) // Service state @@ -127,16 +148,9 @@ class BluetoothConnectionManager( init { connectionScope.launch { - var previousMode: PowerManager.PowerMode? = null powerManager.profile.collect { profile -> - val modeChanged = previousMode != null && previousMode != profile.mode - previousMode = profile.mode if (!isActive || !isBleTransportEnabled()) return@collect - - if (modeChanged && isGattServerEnabled()) { - serverManager.restartAdvertising() - } - clientManager.applyPowerProfile(profile) + applyPowerPolicy(profile) } } // Observe debug settings to enforce role state while active @@ -188,18 +202,23 @@ class BluetoothConnectionManager( */ private fun enforceStrictLimits() { if (!isActive) return - + try { - val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() - val maxOverall = dbg.maxConnectionsOverall.value - val maxServer = dbg.maxServerConnections.value - val maxClient = dbg.maxClientConnections.value - + val limits = effectiveConnectionLimits() + // Get list of connections to evict to satisfy all constraints - val toEvict = connectionTracker.getConnectionsToEvict(maxOverall, maxServer, maxClient) - + val toEvict = connectionTracker.getConnectionsToEvict( + limits.overall, + limits.server, + limits.client + ) + if (toEvict.isNotEmpty()) { - Log.i(TAG, "Enforcing limits (max: $maxOverall, s: $maxServer, c: $maxClient) - evicting ${toEvict.size} connections") + Log.i( + TAG, + "Enforcing limits (max: ${limits.overall}, s: ${limits.server}, " + + "c: ${limits.client}) - evicting ${toEvict.size} connections" + ) toEvict.forEach { conn -> if (conn.isClient) { @@ -213,6 +232,29 @@ class BluetoothConnectionManager( Log.e(TAG, "Error enforcing limits: ${e.message}") } } + + private fun effectiveConnectionLimits(): BleConnectionLimits { + val requested = try { + val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + BleConnectionLimits( + overall = dbg.maxConnectionsOverall.value, + server = dbg.maxServerConnections.value, + client = dbg.maxClientConnections.value + ) + } catch (_: Exception) { + val overall = powerManager.getMaxConnections() + BleConnectionLimits(overall, overall, overall) + } + return runtimePolicy.connectionLimits(requested) + } + + private fun applyPowerPolicy( + profile: PowerManager.RuntimePerformanceProfile = powerManager.profile.value + ) { + if (!isActive || !isBleTransportEnabled()) return + clientManager.applyPowerProfile(profile) + serverManager.applyPowerProfile(profile) + } /** * Start all Bluetooth services with power optimization @@ -278,6 +320,8 @@ class BluetoothConnectionManager( } else { Log.i(TAG, "GATT Client disabled by debug settings; not starting") } + + applyPowerPolicy() Log.i(TAG, "Bluetooth services started successfully") } @@ -339,6 +383,9 @@ class BluetoothConnectionManager( */ fun broadcastPacket(routed: RoutedPacket): Boolean { if (!isActive || !isBleTransportEnabled()) return false + if (routed.transferId != null || routed.packet.type == MessageType.FRAGMENT.value) { + clientManager.requestTransferPriority() + } return packetBroadcaster.broadcastPacket( routed, @@ -359,6 +406,9 @@ class BluetoothConnectionManager( fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { if (!isActive || !isBleTransportEnabled()) return false + if (routed.transferId != null || routed.packet.type == MessageType.FRAGMENT.value) { + clientManager.requestTransferPriority() + } return packetBroadcaster.sendToPeer( peerID, routed, @@ -386,6 +436,7 @@ class BluetoothConnectionManager( fun sendPacketToLink(deviceAddress: String, linkID: String, packet: BitchatPacket): Boolean { if (!isActive || !isBleTransportEnabled()) return false + if (packet.type == MessageType.FRAGMENT.value) clientManager.requestTransferPriority() return packetBroadcaster.sendPacketToLink( RoutedPacket(packet), deviceAddress, @@ -460,6 +511,28 @@ class BluetoothConnectionManager( * Get connected device count */ fun getConnectedDeviceCount(): Int = connectionTracker.getConnectedDeviceCount() + + fun getPowerSnapshot(): BlePowerSnapshot { + val profile = powerManager.profile.value + val limits = effectiveConnectionLimits() + val client = clientManager.getPowerSnapshot() + val server = serverManager.getPowerSnapshot() + return BlePowerSnapshot( + activeConnections = connectionTracker.getConnectedDeviceCount(), + pendingConnections = connectionTracker.getPendingConnectionCount(), + connectionLimit = limits.overall, + scanning = client.scanning, + scanStarts = client.scanStarts, + scanResults = client.scanResults, + scanActiveMs = client.scanActiveMs, + advertising = server.advertising, + advertiseStarts = server.advertiseStarts, + advertiseActiveMs = server.advertiseActiveMs, + rssiReads = client.rssiReads, + background = profile.isBackground, + batteryBand = profile.batteryBand + ) + } /** * Get debug information including power management 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..863850f2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -16,7 +16,8 @@ import java.util.UUID */ class BluetoothConnectionTracker( private val connectionScope: CoroutineScope, - private val powerManager: PowerManager + private val powerManager: PowerManager, + private val onConnectionSlotsChanged: () -> Unit = {} ) : MeshConnectionTracker(connectionScope, TAG) { companion object { @@ -83,6 +84,7 @@ class BluetoothConnectionTracker( addressPeerMap.remove(deviceAddress) } removePendingConnection(deviceAddress) + onConnectionSlotsChanged() // Mark as awaiting first ANNOUNCE on this connection firstAnnounceSeen[deviceAddress] = false } @@ -94,6 +96,7 @@ class BluetoothConnectionTracker( synchronized(connectionStateLock) { connectedDevices[deviceAddress] = deviceConn } + onConnectionSlotsChanged() } fun updateDeviceConnectionIfCurrent( @@ -206,6 +209,12 @@ class BluetoothConnectionTracker( * Get connected device count */ fun getConnectedDeviceCount(): Int = getConnectionCount() + + fun getPendingConnectionCount(): Int = pendingConnections.size + + override fun onPendingConnectionsChanged() { + onConnectionSlotsChanged() + } /** * Check if connection limit is reached @@ -214,9 +223,52 @@ class BluetoothConnectionTracker( * Check if a new client connection is allowed based on limits */ fun canConnectAsClient(maxOverall: Int, maxClient: Int): Boolean { - val total = connectedDevices.size - val clients = connectedDevices.values.count { it.isClient } - return total < maxOverall && clients < maxClient + synchronized(connectionStateLock) { + val pending = pendingConnections.keys.count { !connectedDevices.containsKey(it) } + val total = connectedDevices.size + pending + val clients = connectedDevices.values.count { it.isClient } + pending + return total < maxOverall && clients < maxClient + } + } + + /** + * Atomically reserve capacity for an outbound connection. Counting pending attempts prevents + * simultaneous scan callbacks from exceeding a product's hard limit before GATT connects. + */ + fun tryReserveClientConnection( + deviceAddress: String, + maxOverall: Int, + maxClient: Int + ): Boolean = synchronized(connectionStateLock) { + if (connectedDevices.containsKey(deviceAddress)) return@synchronized false + val pending = pendingConnections.keys.count { !connectedDevices.containsKey(it) } + val activeClients = connectedDevices.values.count { it.isClient } + if (connectedDevices.size + pending >= maxOverall) return@synchronized false + if (activeClients + pending >= maxClient) return@synchronized false + addPendingConnection(deviceAddress) + } + + /** + * Atomically admit and record an inbound link. Keeping the capacity check and map update under + * one lock prevents simultaneous GATT callbacks from briefly exceeding the hard limit. + */ + fun tryAddServerConnection( + deviceAddress: String, + deviceConnection: DeviceConnection, + maxOverall: Int, + maxServer: Int + ): Boolean = synchronized(connectionStateLock) { + if (!connectedDevices.containsKey(deviceAddress)) { + val pending = pendingConnections.keys.count { + it != deviceAddress && !connectedDevices.containsKey(it) + } + val servers = connectedDevices.values.count { !it.isClient } + if (connectedDevices.size + pending >= maxOverall || servers >= maxServer) { + return@synchronized false + } + } + addDeviceConnection(deviceAddress, deviceConnection) + true } /** @@ -278,6 +330,7 @@ class BluetoothConnectionTracker( firstAnnounceSeen.remove(deviceAddress) } Log.d(TAG, "Cleaned up device connection for $deviceAddress") + onConnectionSlotsChanged() } fun cleanupDeviceConnectionIfCurrent( @@ -293,6 +346,7 @@ class BluetoothConnectionTracker( addressPeerMap.remove(deviceAddress) firstAnnounceSeen.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") + onConnectionSlotsChanged() true } else { false 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 40460ceb..ae6f7020 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -1,5 +1,6 @@ package com.bitchat.android.mesh +import android.annotation.SuppressLint import android.bluetooth.* import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback @@ -27,7 +28,12 @@ class BluetoothGattClientManager( private val connectionTracker: BluetoothConnectionTracker, private val permissionManager: BluetoothPermissionManager, private val powerManager: PowerManager, - private val delegate: BluetoothConnectionManagerDelegate? + private val delegate: BluetoothConnectionManagerDelegate?, + private val runtimePolicy: BleRuntimePolicy = DefaultBleRuntimePolicy, + private val connectionLimitsProvider: () -> BleConnectionLimits = { + val max = powerManager.getMaxConnections() + BleConnectionLimits(max, max, max) + } ) { companion object { @@ -67,12 +73,18 @@ class BluetoothGattClientManager( return false } val device = bluetoothAdapter?.getRemoteDevice(deviceAddress) - return if (device != null) { + val limits = connectionLimitsProvider() + return if (device != null && connectionTracker.tryReserveClientConnection( + deviceAddress, + limits.overall, + limits.client + ) + ) { val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50 connectToDevice(device, rssi) true } else { - Log.w(TAG, "connectToAddress: No device for $deviceAddress") + Log.d(TAG, "connectToAddress rejected: unavailable, duplicate, or at connection limit") false } } @@ -97,6 +109,13 @@ class BluetoothGattClientManager( // RSSI monitoring state private var rssiMonitoringJob: Job? = null + private var connectionPriorityRestoreJob: Job? = null + private val powerCounterLock = Any() + private var scanStarts = 0L + private var scanResults = 0L + private var scanActiveMs = 0L + private var scanStartedAt = 0L + private var rssiReads = 0L // State management private var isActive = false @@ -118,7 +137,7 @@ class BluetoothGattClientManager( Log.e(TAG, "Missing Bluetooth permissions") return false } - + if (bluetoothAdapter?.isEnabled != true) { Log.e(TAG, "Bluetooth is not enabled") return false @@ -133,8 +152,6 @@ class BluetoothGattClientManager( connectionScope.launch { applyPowerProfile(powerManager.profile.value) - // Start RSSI monitoring - startRSSIMonitoring() } return true @@ -147,6 +164,8 @@ class BluetoothGattClientManager( scanningDesired = false scanDutyCycleJob?.cancel() scanDutyCycleJob = null + connectionPriorityRestoreJob?.cancel() + connectionPriorityRestoreJob = null stopScanWatchdog() if (!isActive) { // Idempotent stop @@ -189,6 +208,10 @@ class BluetoothGattClientManager( * Start periodic RSSI monitoring for all client connections */ private fun startRSSIMonitoring() { + if (!runtimePolicy.shouldPollRssi(powerManager.profile.value)) { + stopRSSIMonitoring() + return + } rssiMonitoringJob?.cancel() rssiMonitoringJob = connectionScope.launch { while (isActive) { @@ -197,6 +220,7 @@ class BluetoothGattClientManager( val connectedDevices = connectionTracker.getConnectedDevices() connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn -> try { + synchronized(powerCounterLock) { rssiReads++ } deviceConn.gatt?.readRemoteRssi() } catch (e: Exception) { Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") @@ -226,7 +250,9 @@ class BluetoothGattClientManager( private fun startScanning() { // Respect debug setting val enabled = isClientRoleEnabled() - if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return + if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || + !enabled || !scanningDesired + ) return // Rate limit scan starts to prevent "scanning too frequently" errors val currentTime = System.currentTimeMillis() @@ -242,7 +268,7 @@ class BluetoothGattClientManager( // Schedule delayed scan start connectionScope.launch { delay(remainingWait) - if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) { + if (isActive && scanningDesired && !isCurrentlyScanning && isClientRoleEnabled()) { startScanning() } } @@ -269,6 +295,7 @@ class BluetoothGattClientManager( override fun onScanFailed(errorCode: Int) { isCurrentlyScanning = false lastScanStopTime = System.currentTimeMillis() + markScanStopped(lastScanStopTime) when (errorCode) { 1 -> { @@ -305,12 +332,17 @@ class BluetoothGattClientManager( try { lastScanStartTime = currentTime isCurrentlyScanning = true + synchronized(powerCounterLock) { + scanStarts++ + scanStartedAt = currentTime + } bleScanner.startScan(scanFilters, powerManager.getScanSettings(), scanCallback) Log.i(TAG, "BLE scan started") } catch (e: Exception) { Log.e(TAG, "Exception starting scan: ${e.message}") isCurrentlyScanning = false + markScanStopped(System.currentTimeMillis()) } } @@ -319,7 +351,11 @@ class BluetoothGattClientManager( */ @Suppress("DEPRECATION") private fun stopScanning() { - if (!permissionManager.hasBluetoothPermissions() || bleScanner == null) return + if (!permissionManager.hasBluetoothPermissions() || bleScanner == null) { + isCurrentlyScanning = false + markScanStopped(System.currentTimeMillis()) + return + } if (isCurrentlyScanning) { try { @@ -333,6 +369,14 @@ class BluetoothGattClientManager( isCurrentlyScanning = false lastScanStopTime = System.currentTimeMillis() + markScanStopped(lastScanStopTime) + } + } + + private fun markScanStopped(nowMs: Long) = synchronized(powerCounterLock) { + if (scanStartedAt > 0L) { + scanActiveMs += nowMs - scanStartedAt + scanStartedAt = 0L } } @@ -424,6 +468,7 @@ class BluetoothGattClientManager( // Proof the scanner is alive and finding our network: refresh liveness and clear backoff. lastScanResultTime = System.currentTimeMillis() scanRetryCount = 0 + synchronized(powerCounterLock) { scanResults++ } // Try to extract peerID from Service Data (if available) for stable identity val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) @@ -443,30 +488,34 @@ class BluetoothGattClientManager( connectionTracker.updateScanRSSI(deviceAddress, rssi) // Publish scan result to debug UI buffer - try { - DebugSettingsManager.getInstance().addScanResult( - DebugScanResult( - deviceName = device.name, - deviceAddress = deviceAddress, - rssi = rssi, - peerID = peerID // Use the discovered peerID if available - ) - ) - } catch (_: Exception) { } - - // Power-aware RSSI filtering - if (rssi < powerManager.getRSSIThreshold()) { - // Even if we skip connecting, still publish scan result to debug UI + if (runtimePolicy.collectDebugTelemetry) { try { DebugSettingsManager.getInstance().addScanResult( DebugScanResult( deviceName = device.name, deviceAddress = deviceAddress, rssi = rssi, - peerID = peerID + peerID = peerID // Use the discovered peerID if available ) ) } catch (_: Exception) { } + } + + // Power-aware RSSI filtering + if (rssi < powerManager.getRSSIThreshold()) { + // Even if we skip connecting, still publish scan result to debug UI + if (runtimePolicy.collectDebugTelemetry) { + try { + DebugSettingsManager.getInstance().addScanResult( + DebugScanResult( + deviceName = device.name, + deviceAddress = deviceAddress, + rssi = rssi, + peerID = peerID + ) + ) + } catch (_: Exception) { } + } return } @@ -481,16 +530,13 @@ class BluetoothGattClientManager( } // Check if connection limit is reached - val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } - val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections() - val maxClient = dbg?.maxClientConnections?.value ?: maxOverall - - if (!connectionTracker.canConnectAsClient(maxOverall, maxClient)) { - return - } - - // Add pending connection and start connection - if (connectionTracker.addPendingConnection(deviceAddress)) { + val limits = connectionLimitsProvider() + if (connectionTracker.tryReserveClientConnection( + deviceAddress, + limits.overall, + limits.client + ) + ) { connectToDevice(device, rssi, peerID) } } @@ -510,6 +556,7 @@ class BluetoothGattClientManager( val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { + applyConnectionPriority(gatt, powerManager.profile.value) // Request a larger MTU. Must be done before any data transfer. connectionScope.launch { delay(200) // A small delay can improve reliability of MTU request. @@ -673,6 +720,16 @@ class BluetoothGattClientManager( * Apply the current process-wide profile without ever disabling background discovery. */ fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) { + if (runtimePolicy.shouldPollRssi(profile)) { + if (rssiMonitoringJob?.isActive != true) startRSSIMonitoring() + } else { + stopRSSIMonitoring() + } + connectionTracker.getConnectedDevices().values + .filter { it.isClient } + .mapNotNull { it.gatt } + .forEach { applyConnectionPriority(it, profile) } + scanDutyCycleJob?.cancel() scanDutyCycleJob = null if (!isActive || !isClientRoleEnabled()) { @@ -680,7 +737,16 @@ class BluetoothGattClientManager( return } - if (profile.ble.continuousScan) { + val occupiedSlots = connectionTracker.getConnectedDeviceCount() + + connectionTracker.getPendingConnectionCount() + val plan = runtimePolicy.scanPlan(profile, occupiedSlots) + if (!plan.enabled) { + stopScanWatchdog() + onScanStateChanged(false) + return + } + + if (plan.continuous) { startScanWatchdog() onScanStateChanged(true) return @@ -692,11 +758,68 @@ class BluetoothGattClientManager( scanDutyCycleJob = connectionScope.launch { while (isActive && isClientRoleEnabled()) { onScanStateChanged(true) - delay(profile.ble.scanOnMs) + delay(plan.scanOnMs) if (!isActive || !isClientRoleEnabled()) break onScanStateChanged(false) - delay(profile.ble.scanOffMs) + delay(plan.scanOffMs) } } } -} + + @SuppressLint("MissingPermission") + private fun applyConnectionPriority( + gatt: BluetoothGatt, + profile: PowerManager.RuntimePerformanceProfile + ) { + val priority = runtimePolicy.gattConnectionPriority(profile) ?: return + try { + gatt.requestConnectionPriority(priority) + } catch (e: Exception) { + Log.d(TAG, "Unable to update GATT connection priority: ${e.message}") + } + } + + @SuppressLint("MissingPermission") + fun requestTransferPriority() { + val priority = runtimePolicy.transferGattConnectionPriority() ?: return + connectionTracker.getConnectedDevices().values + .filter { it.isClient } + .mapNotNull { it.gatt } + .forEach { gatt -> + try { gatt.requestConnectionPriority(priority) } catch (_: Exception) { } + } + connectionPriorityRestoreJob?.cancel() + connectionPriorityRestoreJob = connectionScope.launch { + delay(runtimePolicy.transferPriorityDurationMs()) + val profile = powerManager.profile.value + connectionTracker.getConnectedDevices().values + .filter { it.isClient } + .mapNotNull { it.gatt } + .forEach { applyConnectionPriority(it, profile) } + } + } + + internal data class PowerSnapshot( + val scanning: Boolean, + val scanStarts: Long, + val scanResults: Long, + val scanActiveMs: Long, + val rssiReads: Long + ) + + internal fun getPowerSnapshot(nowMs: Long = System.currentTimeMillis()): PowerSnapshot = + synchronized(powerCounterLock) { + val activeMs = if (isCurrentlyScanning && scanStartedAt > 0L) { + nowMs - scanStartedAt + } else { + 0L + } + PowerSnapshot( + scanning = isCurrentlyScanning, + scanStarts = scanStarts, + scanResults = scanResults, + scanActiveMs = scanActiveMs + activeMs, + rssiReads = rssiReads + ) + } +} 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..8cd7766a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -26,7 +26,12 @@ class BluetoothGattServerManager( private val permissionManager: BluetoothPermissionManager, private val powerManager: PowerManager, private val delegate: BluetoothConnectionManagerDelegate?, - private val myPeerID: String + private val myPeerID: String, + private val runtimePolicy: BleRuntimePolicy = DefaultBleRuntimePolicy, + private val connectionLimitsProvider: () -> BleConnectionLimits = { + val max = powerManager.getMaxConnections() + BleConnectionLimits(max, max, max) + } ) { companion object { @@ -48,6 +53,13 @@ class BluetoothGattServerManager( private var characteristic: BluetoothGattCharacteristic? = null private var advertiseCallback: AdvertiseCallback? = null private var advertiseRetryCount = 0 + @Volatile private var isAdvertising = false + @Volatile private var advertiseStartPending = false + private var advertisedMode: PowerManager.PowerMode? = null + private val powerCounterLock = Any() + private var advertiseStarts = 0L + private var advertiseActiveMs = 0L + private var advertiseStartedAt = 0L // State management private var isActive = false @@ -109,7 +121,7 @@ class BluetoothGattServerManager( connectionScope.launch { setupGattServer() delay(300) // Brief delay to ensure GATT server is ready - startAdvertising() + applyPowerProfile(powerManager.profile.value) } return true @@ -178,20 +190,30 @@ class BluetoothGattServerManager( when (newState) { BluetoothProfile.STATE_CONNECTED -> { - Log.i(TAG, "Connected to ${device.address} (server)") + val limits = connectionLimitsProvider() val linkID = UUID.randomUUID().toString() - serverLinkIDs[device.address] = linkID - + // Get best available RSSI (scan RSSI for server connections) val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE - val deviceConn = BluetoothConnectionTracker.DeviceConnection( device = device, rssi = rssi, isClient = false, linkID = linkID ) - connectionTracker.addDeviceConnection(device.address, deviceConn) + if (!connectionTracker.tryAddServerConnection( + device.address, + deviceConn, + limits.overall, + limits.server + ) + ) { + Log.i(TAG, "Rejecting inbound GATT connection: connection limit reached") + try { gattServer?.cancelConnection(device) } catch (_: Exception) { } + return + } + Log.i(TAG, "Connected to ${device.address} (server)") + serverLinkIDs[device.address] = linkID connectionScope.launch { delay(1000) @@ -375,6 +397,15 @@ class BluetoothGattServerManager( Log.d(TAG, "Not starting advertising: GATT Server disabled via debug settings") return } + if (!runtimePolicy.shouldAdvertise( + powerManager.profile.value, + occupiedConnectionSlots() + ) + ) { + stopAdvertising() + return + } + if (isAdvertising || advertiseStartPending) return if (bleAdvertiser == null) { Log.w(TAG, "Not starting advertising: BLE advertiser not available on this device") return @@ -408,7 +439,18 @@ class BluetoothGattServerManager( advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + advertiseStartPending = false + if (!isActive || !isServerRoleEnabled() || !runtimePolicy.shouldAdvertise( + powerManager.profile.value, + occupiedConnectionSlots() + ) + ) { + stopAdvertising() + return + } advertiseRetryCount = 0 + advertisedMode = powerManager.profile.value.mode + markAdvertisingStarted() val mode = try { powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0] } catch (_: Exception) { "unknown" } @@ -416,6 +458,8 @@ class BluetoothGattServerManager( } override fun onStartFailure(errorCode: Int) { + advertiseStartPending = false + markAdvertisingStopped() Log.e(TAG, "Advertising failed: $errorCode") // Previously this only logged, so if advertising failed this device became // undiscoverable until a manual BLE toggle. Retry transient failures with backoff. @@ -437,10 +481,13 @@ class BluetoothGattServerManager( } try { + advertiseStartPending = true bleAdvertiser.startAdvertising(settings, data, scanResponse, advertiseCallback) } catch (se: SecurityException) { + advertiseStartPending = false Log.e(TAG, "SecurityException starting advertising (missing permission?): ${se.message}") } catch (e: Exception) { + advertiseStartPending = false Log.e(TAG, "Exception starting advertising: ${e.message}") } } @@ -450,13 +497,67 @@ class BluetoothGattServerManager( */ @Suppress("DEPRECATION") private fun stopAdvertising() { - if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null) return + advertiseStartPending = false + if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null) { + markAdvertisingStopped() + return + } try { advertiseCallback?.let { cb -> bleAdvertiser.stopAdvertising(cb) } } catch (e: Exception) { Log.w(TAG, "Error stopping advertising: ${e.message}") + } finally { + advertiseCallback = null + advertisedMode = null + markAdvertisingStopped() } } + + fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) { + if (!isActive || !isServerRoleEnabled()) { + stopAdvertising() + return + } + val shouldAdvertise = runtimePolicy.shouldAdvertise( + profile, + occupiedConnectionSlots() + ) + when { + !shouldAdvertise -> stopAdvertising() + isAdvertising && advertisedMode != profile.mode -> restartAdvertising() + !isAdvertising && !advertiseStartPending -> connectionScope.launch { startAdvertising() } + } + } + + internal data class PowerSnapshot( + val advertising: Boolean, + val advertiseStarts: Long, + val advertiseActiveMs: Long + ) + + internal fun getPowerSnapshot(nowMs: Long = System.currentTimeMillis()): PowerSnapshot = + synchronized(powerCounterLock) { + val activeMs = if (isAdvertising && advertiseStartedAt > 0L) { + nowMs - advertiseStartedAt + } else { + 0L + } + PowerSnapshot(isAdvertising, advertiseStarts, advertiseActiveMs + activeMs) + } + + private fun markAdvertisingStarted() = synchronized(powerCounterLock) { + if (isAdvertising) return@synchronized + isAdvertising = true + advertiseStarts++ + advertiseStartedAt = System.currentTimeMillis() + } + + private fun markAdvertisingStopped() = synchronized(powerCounterLock) { + if (!isAdvertising) return@synchronized + advertiseActiveMs += System.currentTimeMillis() - advertiseStartedAt + advertiseStartedAt = 0L + isAdvertising = false + } /** * Schedule an advertising restart with incremental backoff after a transient failure. @@ -467,7 +568,11 @@ class BluetoothGattServerManager( Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)") connectionScope.launch { delay(delayMs) - if (isActive && isServerRoleEnabled()) { + if (isActive && isServerRoleEnabled() && runtimePolicy.shouldAdvertise( + powerManager.profile.value, + occupiedConnectionSlots() + ) + ) { stopAdvertising() delay(100) startAdvertising() @@ -481,7 +586,11 @@ class BluetoothGattServerManager( fun restartAdvertising() { // Respect debug setting val enabled = isServerRoleEnabled() - if (!isActive || !enabled) { + if (!isActive || !enabled || !runtimePolicy.shouldAdvertise( + powerManager.profile.value, + occupiedConnectionSlots() + ) + ) { stopAdvertising() return } @@ -492,4 +601,7 @@ class BluetoothGattServerManager( startAdvertising() } } + + private fun occupiedConnectionSlots(): Int = + connectionTracker.getConnectedDeviceCount() + connectionTracker.getPendingConnectionCount() } diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt index 3a10f998..fcc8a70d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt @@ -6,6 +6,7 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.FragmentPayload import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import java.util.concurrent.ConcurrentHashMap /** @@ -42,6 +43,7 @@ class FragmentManager { // Coroutines private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val cleanupSignal = Channel(Channel.CONFLATED) init { startPeriodicCleanup() @@ -218,6 +220,7 @@ class FragmentManager { System.currentTimeMillis() ) fragmentCumulativeSize[fragmentIDString] = 0 + cleanupSignal.trySend(Unit) } val fragmentMap = incomingFragments[fragmentIDString] @@ -357,8 +360,11 @@ class FragmentManager { private fun startPeriodicCleanup() { managerScope.launch { while (isActive) { - delay(CLEANUP_INTERVAL) - cleanupOldFragments() + if (cleanupSignal.receiveCatching().getOrNull() == null) break + while (isActive && fragmentMetadata.isNotEmpty()) { + delay(CLEANUP_INTERVAL) + cleanupOldFragments() + } } } } @@ -379,6 +385,7 @@ class FragmentManager { * Shutdown the manager */ fun shutdown() { + cleanupSignal.close() managerScope.cancel() clearAllFragments() } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt index 0dd6a552..a68af85e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt @@ -3,6 +3,7 @@ package com.bitchat.android.mesh import android.util.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -43,13 +44,13 @@ abstract class MeshConnectionTracker( protected val pendingConnections = ConcurrentHashMap() private var isActive = false + @Volatile private var cleanupJob: Job? = null /** * Start the tracker and its cleanup loop */ open fun start() { isActive = true - startPeriodicCleanup() } /** @@ -57,7 +58,12 @@ abstract class MeshConnectionTracker( */ open fun stop() { isActive = false - pendingConnections.clear() + cleanupJob?.cancel() + cleanupJob = null + if (pendingConnections.isNotEmpty()) { + pendingConnections.clear() + onPendingConnectionsChanged() + } } /** @@ -92,6 +98,8 @@ abstract class MeshConnectionTracker( val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 pendingConnections[id] = ConnectionAttempt(attempts) Log.d(tag, "Added pending connection for $id (attempts: $attempts)") + onPendingConnectionsChanged() + scheduleCleanupIfNeeded() return true } } @@ -100,9 +108,11 @@ abstract class MeshConnectionTracker( * Remove a pending attempt (e.g., on success or fatal error) */ fun removePendingConnection(id: String) { - pendingConnections.remove(id) + if (pendingConnections.remove(id) != null) onPendingConnectionsChanged() } + protected open fun onPendingConnectionsChanged() = Unit + /** * Abstract: Subclasses must define what "connected" means */ @@ -118,9 +128,10 @@ abstract class MeshConnectionTracker( */ abstract fun getConnectionCount(): Int - private fun startPeriodicCleanup() { - scope.launch { - while (isActive) { + private fun scheduleCleanupIfNeeded() { + if (!isActive || cleanupJob?.isActive == true) return + cleanupJob = scope.launch { + while (isActive && pendingConnections.isNotEmpty()) { try { delay(CLEANUP_INTERVAL) if (!isActive) break @@ -130,6 +141,7 @@ abstract class MeshConnectionTracker( expired.keys.forEach { pendingConnections.remove(it) } if (expired.isNotEmpty()) { + onPendingConnectionsChanged() Log.d(tag, "Cleaned up ${expired.size} expired connection attempts") } } catch (e: CancellationException) { @@ -138,6 +150,8 @@ abstract class MeshConnectionTracker( Log.w(tag, "Error in periodic cleanup: ${e.message}") } } + cleanupJob = null + if (isActive && pendingConnections.isNotEmpty()) scheduleCleanupIfNeeded() } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 525487e5..166953a7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -4,6 +4,7 @@ import android.util.Log import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.PeerCapabilities import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList @@ -108,6 +109,7 @@ class PeerManager { // Coroutines private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val cleanupSignal = Channel(Channel.CONFLATED) init { startPeriodicCleanup() @@ -191,6 +193,7 @@ class PeerManager { .takeIf { announcementMatchesAuthenticatedState } ) peers[peerID] = replacement + cleanupSignal.trySend(Unit) if (existing == null || existing != replacement) notifyPeerListUpdate() } @@ -240,6 +243,7 @@ class PeerManager { ) peers[peerID] = peerInfo + cleanupSignal.trySend(Unit) // Update derived state only // No legacy maps; peers map is the single source of truth @@ -310,6 +314,7 @@ class PeerManager { if (peerID != "unknown") { peers[peerID]?.let { info -> peers[peerID] = info.copy(lastSeen = System.currentTimeMillis()) + cleanupSignal.trySend(Unit) } } } @@ -357,6 +362,7 @@ class PeerManager { lastSeen = now ) } + cleanupSignal.trySend(Unit) // Handle first announcement if (isFirstAnnounce) { @@ -542,8 +548,11 @@ class PeerManager { private fun startPeriodicCleanup() { managerScope.launch { while (isActive) { - delay(com.bitchat.android.util.AppConstants.Mesh.PEER_CLEANUP_INTERVAL_MS) - cleanupStalePeers() + if (cleanupSignal.receiveCatching().getOrNull() == null) break + while (isActive && peers.isNotEmpty()) { + delay(com.bitchat.android.util.AppConstants.Mesh.PEER_CLEANUP_INTERVAL_MS) + cleanupStalePeers() + } } } } @@ -649,6 +658,7 @@ class PeerManager { * Shutdown the manager */ fun shutdown() { + cleanupSignal.close() managerScope.cancel() clearAllPeers() } diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 3f684679..970cf3ec 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -9,6 +9,7 @@ import com.bitchat.android.noise.AuthenticatedNoiseSession import com.bitchat.android.noise.NoiseDecryptionResult import com.bitchat.android.util.toHexString import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import java.util.* import kotlin.collections.mutableSetOf @@ -39,6 +40,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private // Coroutines private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val cleanupSignal = Channel(Channel.CONFLATED) init { startPeriodicCleanup() @@ -98,6 +100,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private // later legitimate packet with the same timestamp and payload. processedMessages.add(messageID) messageTimestamps[messageID] = currentTime + cleanupSignal.trySend(Unit) return true } @@ -137,6 +140,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID) processedKeyExchanges.add(exchangeKey) keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis() + cleanupSignal.trySend(Unit) if (result.response != null) { // Send handshake response through delegate @@ -388,11 +392,17 @@ class SecurityManager(private val encryptionService: EncryptionService, private private fun startPeriodicCleanup() { managerScope.launch { while (isActive) { - delay(CLEANUP_INTERVAL) - cleanupOldData() + if (cleanupSignal.receiveCatching().getOrNull() == null) break + while (isActive && hasCleanupState()) { + delay(CLEANUP_INTERVAL) + cleanupOldData() + } } } } + + private fun hasCleanupState(): Boolean = + messageTimestamps.isNotEmpty() || keyExchangeTimestamps.isNotEmpty() /** * Clean up old processed messages and timestamps @@ -462,6 +472,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private * Shutdown the manager */ fun shutdown() { + cleanupSignal.close() managerScope.cancel() clearAllData() } diff --git a/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt b/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt index d9dd18d0..be04c0ca 100644 --- a/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt @@ -5,6 +5,7 @@ import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -43,6 +44,7 @@ class StoreForwardManager { // Coroutines private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val cleanupSignal = Channel(Channel.CONFLATED) init { startPeriodicCleanup() @@ -105,6 +107,7 @@ class StoreForwardManager { cleanupMessageCache() messageCache.add(storedMessage) + cleanupSignal.trySend(Unit) // Limit cache size if (messageCache.size > MAX_CACHED_MESSAGES) { @@ -162,6 +165,9 @@ class StoreForwardManager { // Mark as delivered val messageIDsToRemove = messagesToSend.map { it.messageID } deliveredMessages.addAll(messageIDsToRemove) + if (deliveredMessages.size > 1000 || cachedMessagesSentToPeer.size > 200) { + cleanupSignal.trySend(Unit) + } // Send with delays to avoid overwhelming the connection messagesToSend.forEachIndexed { index, storedMessage -> @@ -194,6 +200,7 @@ class StoreForwardManager { */ fun markMessageAsDelivered(messageID: String) { deliveredMessages.add(messageID) + if (deliveredMessages.size > 1000) cleanupSignal.trySend(Unit) } /** @@ -242,12 +249,20 @@ class StoreForwardManager { private fun startPeriodicCleanup() { managerScope.launch { while (isActive) { - delay(CLEANUP_INTERVAL) - cleanupMessageCache() - cleanupDeliveredMessages() + if (cleanupSignal.receiveCatching().getOrNull() == null) break + while (isActive && hasPeriodicCleanupWork()) { + delay(CLEANUP_INTERVAL) + cleanupMessageCache() + cleanupDeliveredMessages() + } } } } + + private fun hasPeriodicCleanupWork(): Boolean = + messageCache.isNotEmpty() || + deliveredMessages.size > 1000 || + cachedMessagesSentToPeer.size > 200 /** * Clean up old cached messages (not for favorites) @@ -301,6 +316,7 @@ class StoreForwardManager { * Shutdown the manager */ fun shutdown() { + cleanupSignal.close() managerScope.cancel() clearAllCache() } diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 42a10ceb..06ebb969 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -29,6 +29,9 @@ class GossipSyncManager( fun seenCapacity(): Int // max packets we sync per request (cap across types) fun gcsMaxBytes(): Int fun gcsTargetFpr(): Double // percent -> 0.0..1.0 + fun periodicSyncIntervalMs(): Long? = 30_000L + fun cleanupIntervalMs(): Long? = + com.bitchat.android.util.AppConstants.Sync.CLEANUP_INTERVAL_MS } companion object { @@ -51,25 +54,29 @@ class GossipSyncManager( private var cleanupJob: Job? = null fun start() { periodicJob?.cancel() - periodicJob = scope.launch(Dispatchers.IO) { - while (isActive) { - try { - delay(30_000) - sendRequestSync() - } catch (e: CancellationException) { throw e } - catch (e: Exception) { Log.e(TAG, "Periodic sync error: ${e.message}") } + periodicJob = configProvider.periodicSyncIntervalMs()?.let { intervalMs -> + scope.launch(Dispatchers.IO) { + while (isActive) { + try { + delay(intervalMs) + sendRequestSync() + } catch (e: CancellationException) { throw e } + catch (e: Exception) { Log.e(TAG, "Periodic sync error: ${e.message}") } + } } } // Start periodic cleanup of stale announcements and messages cleanupJob?.cancel() - cleanupJob = scope.launch(Dispatchers.IO) { - while (isActive) { - try { - delay(com.bitchat.android.util.AppConstants.Sync.CLEANUP_INTERVAL_MS) - pruneStaleAnnouncements() - } catch (e: CancellationException) { throw e } - catch (e: Exception) { Log.e(TAG, "Periodic cleanup error: ${e.message}") } + cleanupJob = configProvider.cleanupIntervalMs()?.let { intervalMs -> + scope.launch(Dispatchers.IO) { + while (isActive) { + try { + delay(intervalMs) + pruneStaleAnnouncements() + } catch (e: CancellationException) { throw e } + catch (e: Exception) { Log.e(TAG, "Periodic cleanup error: ${e.message}") } + } } } } @@ -140,6 +147,10 @@ class GossipSyncManager( if (it.hasNext()) { it.next(); it.remove() } else break } } + + // Products that disable the cleanup timer (notably Wear) prune opportunistically when + // mesh traffic already woke the process. + if (configProvider.cleanupIntervalMs() == null) pruneStaleAnnouncements() } private fun sendRequestSync() { @@ -175,6 +186,7 @@ class GossipSyncManager( } fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { + if (configProvider.cleanupIntervalMs() == null) pruneStaleAnnouncements() // Decode GCS into sorted set for membership checks val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) fun mightContain(id: ByteArray): Boolean { diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionAdmissionTest.kt new file mode 100644 index 00000000..e90cfa66 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionAdmissionTest.kt @@ -0,0 +1,103 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class BluetoothConnectionAdmissionTest { + private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + private val tracker = BluetoothConnectionTracker(scope, mock()) + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `pending client reservations cannot race past two total links`() { + tracker.start() + + assertTrue(tracker.tryReserveClientConnection("first", 2, 2)) + assertTrue(tracker.tryReserveClientConnection("second", 2, 2)) + assertFalse(tracker.tryReserveClientConnection("third", 2, 2)) + assertEquals(2, tracker.getPendingConnectionCount()) + } + + @Test + fun `active and pending links share the same hard limit`() { + tracker.start() + addConnection("active", isClient = false) + + assertTrue(tracker.tryReserveClientConnection("pending", 2, 2)) + assertFalse(tracker.tryReserveClientConnection("excess", 2, 2)) + assertFalse(tryAddServerConnection("server")) + } + + @Test + fun `two server links are accepted but a third is rejected`() { + assertTrue(tryAddServerConnection("server-a")) + assertTrue(tryAddServerConnection("server-b")) + + assertFalse(tryAddServerConnection("server-c")) + } + + @Test + fun `simultaneous inbound callbacks cannot race past two links`() { + val executor = Executors.newFixedThreadPool(8) + val start = CountDownLatch(1) + try { + val admissions = (0 until 8).map { index -> + executor.submit { + start.await() + tryAddServerConnection("server-$index") + } + } + start.countDown() + + assertEquals(2, admissions.count { it.get(5, TimeUnit.SECONDS) }) + assertEquals(2, tracker.getConnectedDeviceCount()) + } finally { + executor.shutdownNow() + } + } + + private fun tryAddServerConnection(address: String): Boolean { + val device = mock() + whenever(device.address).thenReturn(address) + return tracker.tryAddServerConnection( + address, + BluetoothConnectionTracker.DeviceConnection( + device = device, + isClient = false, + linkID = "link-$address" + ), + maxOverall = 2, + maxServer = 2 + ) + } + + private fun addConnection(address: String, isClient: Boolean) { + val device = mock() + whenever(device.address).thenReturn(address) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection( + device = device, + isClient = isClient, + linkID = "link-$address" + ) + ) + } +} diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 8de1e063..d9d4474a 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -298,6 +298,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `dm` | Noise handshake both ways, encrypted DM round trips with content match | | `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification | | `broadcast` | public mesh message A→B | +| `watch_power` | phone→Watch screen-off delivery, two-link ceiling, scan quiescence, and zero background RSSI polling | | `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption | | `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions | | `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | @@ -329,7 +330,13 @@ See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`, `announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`, `favorite_status`, `verification_set`, `verification_status`, `file_send`, `file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`, -`clear_results`). +`clear_results`). The Watch hook additionally exposes `power`, which returns only bounded BLE +policy counters suitable for local power regression checks. + +`watch_power` requires `--serial-watch`. It backgrounds and sleeps the Watch activity while +leaving the foreground mesh service running, then restores the Watch to the foreground. It is a +short policy regression test, not an endurance or battery-drain measurement. Run longer unplugged +Battery Stats or Power Profiler comparisons separately when evaluating energy savings. ### Troubleshooting diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 7abe8085..1be4b2ee 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -292,6 +292,13 @@ class WatchDevice(Device): _shell(self.serial, "settings put system screen_off_timeout 600000") _shell(self.serial, "input keyevent KEYCODE_WAKEUP") + def background_and_sleep(self) -> None: + """Background the activity and turn the display off without stopping its mesh FGS.""" + _shell(self.serial, "input keyevent KEYCODE_HOME") + time.sleep(1) + _shell(self.serial, "input keyevent KEYCODE_SLEEP") + time.sleep(5) + # MARK: - fixtures @@ -532,6 +539,69 @@ def scenario_broadcast(a: Device, b: Device) -> dict: return {"send": send_result, "recv": recv_result} +def scenario_watch_power(a: Device, b: Device) -> dict: + """Assert the Wear screen-off radio policy while preserving live message delivery.""" + if not isinstance(b, WatchDevice): + raise MeshLabError("watch_power requires --serial-watch") + + before = b.cmd_ok("power") + if before.get("connection_limit") != 2: + raise MeshLabError(f"watch connection limit is not two: {before}") + if not 1 <= before.get("active_connections", 0) <= 2: + raise MeshLabError(f"watch does not have a bounded active link: {before}") + + try: + b.background_and_sleep() + # Allow ProcessLifecycleOwner, the one-second scan window, and advertising mode changes + # to settle before taking the first background snapshot. + time.sleep(8) + background_first = b.cmd_ok("power") + time.sleep(15) + background_second = b.cmd_ok("power") + + if not background_second.get("background"): + raise MeshLabError(f"watch did not enter its background profile: {background_second}") + if background_second.get("scanning"): + raise MeshLabError(f"watch kept scanning with a stable link: {background_second}") + active_connections = background_second.get("active_connections", 0) + if active_connections < 2 and not background_second.get("advertising"): + raise MeshLabError( + f"watch cannot accept a second routing link while one slot is free: {background_second}" + ) + if active_connections == 2 and background_second.get("advertising"): + raise MeshLabError(f"watch kept advertising with both link slots occupied: {background_second}") + if background_second.get("scan_active_ms") != background_first.get("scan_active_ms"): + raise MeshLabError( + "watch accumulated scan time during the settled background interval: " + f"{background_first.get('scan_active_ms')} -> " + f"{background_second.get('scan_active_ms')}" + ) + if background_second.get("rssi_reads") != background_first.get("rssi_reads"): + raise MeshLabError( + "watch continued RSSI polling while backgrounded: " + f"{background_first.get('rssi_reads')} -> {background_second.get('rssi_reads')}" + ) + if active_connections > 2: + raise MeshLabError(f"watch exceeded its two-link ceiling: {background_second}") + + token = f"watch-power-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "msg_recv", 60_000, contains=token) + time.sleep(2) + send = pool.submit(a.cmd_ok, "broadcast_msg", 30_000, content=token) + recv_result, send_result = recv.result(), send.result() + + return { + "foreground": before, + "background_settled": background_first, + "background_verified": background_second, + "delivery": {"send": send_result, "recv": recv_result}, + } + finally: + b.wake() + b.launch() + + def _ptt_one_way( sender: Device, receiver: Device, @@ -869,6 +939,7 @@ SCENARIOS = { "dm": scenario_dm, "favorite_verification": scenario_favorite_verification, "broadcast": scenario_broadcast, + "watch_power": scenario_watch_power, "ptt_dm": scenario_ptt_dm, "ptt_broadcast": scenario_ptt_broadcast, # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only @@ -899,6 +970,7 @@ WATCH_SCENARIOS = [ "dm", "favorite_verification", "broadcast", + "watch_power", "ptt_dm", "ptt_broadcast", "raw", diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt index 1f6d5cd8..407b030d 100644 --- a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -73,6 +73,7 @@ object WearTestHookDriver { "file_recv" -> fileRecv(context, intent) "ptt_recv" -> pttRecv(context, intent) "ptt_send" -> pttSend(context, intent) + "power" -> power(context) "state" -> state(context) "clear_results" -> clearResults(context) else -> err(cmd, "unknown command: $cmd") @@ -489,6 +490,24 @@ object WearTestHookDriver { // MARK: - State + private fun power(context: Context): JSONObject { + val snapshot = mesh(context).getBlePowerSnapshot() + return ok("power") + .put("active_connections", snapshot.activeConnections) + .put("pending_connections", snapshot.pendingConnections) + .put("connection_limit", snapshot.connectionLimit) + .put("scanning", snapshot.scanning) + .put("scan_starts", snapshot.scanStarts) + .put("scan_results", snapshot.scanResults) + .put("scan_active_ms", snapshot.scanActiveMs) + .put("advertising", snapshot.advertising) + .put("advertise_starts", snapshot.advertiseStarts) + .put("advertise_active_ms", snapshot.advertiseActiveMs) + .put("rssi_reads", snapshot.rssiReads) + .put("background", snapshot.background) + .put("battery_band", snapshot.batteryBand.name) + } + private fun state(context: Context): JSONObject { val mesh = mesh(context) val peersJson = peerInfosJson(mesh, AppStateStore.peers.value) @@ -503,6 +522,7 @@ object WearTestHookDriver { .put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList())) .put("sessions", sessions) .put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>)) + .put("power", power(context)) .put("debug_status", mesh.getDebugStatus()) } diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearBleRuntimePolicy.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearBleRuntimePolicy.kt new file mode 100644 index 00000000..ec14980e --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearBleRuntimePolicy.kt @@ -0,0 +1,82 @@ +package com.bitchat.watch.mesh + +import android.bluetooth.BluetoothGatt +import com.bitchat.android.mesh.BleConnectionLimits +import com.bitchat.android.mesh.BleRuntimePolicy +import com.bitchat.android.mesh.BleScanPlan +import com.bitchat.android.mesh.PowerManager + +/** + * Aggressive Wear policy: at most two links, no discovery once full, and sparse discovery while + * an existing background link can already carry messages and route traffic. + */ +object WearBleRuntimePolicy : BleRuntimePolicy { + const val MAX_CONNECTIONS = 2 + + private const val BACKGROUND_ONE_LINK_SCAN_ON_MS = 1_000L + private const val BACKGROUND_ONE_LINK_SCAN_OFF_MS = 299_000L + private const val BACKGROUND_NO_LINK_NORMAL_OFF_MS = 59_000L + private const val BACKGROUND_NO_LINK_LOW_OFF_MS = 119_000L + private const val BACKGROUND_NO_LINK_CRITICAL_OFF_MS = 299_000L + + // The Watch has no packet-debug UI. Avoid allocating telemetry queues even in debug builds; + // the transport's fixed-size power counters remain available to Mesh Lab. + override val collectDebugTelemetry: Boolean = false + + override fun connectionLimits(requested: BleConnectionLimits): BleConnectionLimits = + BleConnectionLimits( + overall = requested.overall.coerceAtMost(MAX_CONNECTIONS), + server = requested.server.coerceAtMost(MAX_CONNECTIONS), + client = requested.client.coerceAtMost(MAX_CONNECTIONS) + ) + + override fun scanPlan( + profile: PowerManager.RuntimePerformanceProfile, + activeConnections: Int + ): BleScanPlan { + if (activeConnections >= MAX_CONNECTIONS) return BleScanPlan(enabled = false) + + if (!profile.isBackground) { + // Foreground discovery stays responsive. Even while charging, avoid an unbounded + // low-latency scan on a watch; an 8 s burst followed by a short pause is enough. + return BleScanPlan( + enabled = true, + scanOnMs = 8_000L, + scanOffMs = 2_000L, + continuous = false + ) + } + + if (activeConnections == 1) { + return BleScanPlan( + enabled = true, + scanOnMs = BACKGROUND_ONE_LINK_SCAN_ON_MS, + scanOffMs = BACKGROUND_ONE_LINK_SCAN_OFF_MS + ) + } + + val offMs = when (profile.batteryBand) { + PowerManager.BatteryBand.NORMAL -> BACKGROUND_NO_LINK_NORMAL_OFF_MS + PowerManager.BatteryBand.LOW -> BACKGROUND_NO_LINK_LOW_OFF_MS + PowerManager.BatteryBand.CRITICAL -> BACKGROUND_NO_LINK_CRITICAL_OFF_MS + } + return BleScanPlan(enabled = true, scanOnMs = 1_000L, scanOffMs = offMs) + } + + override fun shouldAdvertise( + profile: PowerManager.RuntimePerformanceProfile, + activeConnections: Int + ): Boolean = activeConnections < MAX_CONNECTIONS + + override fun shouldPollRssi(profile: PowerManager.RuntimePerformanceProfile): Boolean = + !profile.isBackground + + override fun gattConnectionPriority(profile: PowerManager.RuntimePerformanceProfile): Int = + if (profile.isBackground) { + BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER + } else { + BluetoothGatt.CONNECTION_PRIORITY_BALANCED + } + + override fun transferGattConnectionPriority(): Int = BluetoothGatt.CONNECTION_PRIORITY_HIGH +} diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index eeb09295..ede3dbd9 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Watch mesh service: composes the shared BLE transport (BluetoothConnectionManager) with the @@ -52,6 +53,7 @@ class WearMeshService private constructor(private val context: Context) { private val bleTransport = BleTransport() private val meshCore: MeshCore private var connectionManager: BluetoothConnectionManager + private val initialSyncLinkIDs = ConcurrentHashMap.newKeySet() @Volatile var nickname: String = loadNickname() @@ -76,6 +78,8 @@ class WearMeshService private constructor(private val context: Context) { override fun seenCapacity(): Int = 500 override fun gcsMaxBytes(): Int = 400 override fun gcsTargetFpr(): Double = 0.01 + override fun periodicSyncIntervalMs(): Long? = null + override fun cleanupIntervalMs(): Long? = null }, hooks = MeshCore.Hooks( onMessageReceived = { message -> handleMessageReceived(message) }, @@ -90,23 +94,22 @@ class WearMeshService private constructor(private val context: Context) { ) if (observed) { meshCore.setDirectConnection(obs.peerID, true) - try { + if (initialSyncLinkIDs.add(obs.ingressLinkID)) try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(obs.peerID, 1_000) } catch (_: Exception) { } } } - routed.peerID?.let { pid -> - maybeAutoHandshake(pid) - try { - meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) - } catch (_: Exception) { } - } }, announcementNicknameProvider = { nickname }, leavePayloadProvider = { nickname.toByteArray(Charsets.UTF_8) } ) ) - connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) + connectionManager = BluetoothConnectionManager( + context, + myPeerID, + meshCore.fragmentManager, + WearBleRuntimePolicy + ) bleTransport.connectionManager = connectionManager wireBluetoothDelegate() } @@ -148,15 +151,6 @@ class WearMeshService private constructor(private val context: Context) { device: BluetoothDevice?, ingressLinkID: String ) { - try { - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( - packet = packet, - fromPeerID = peerID, - fromNickname = null, - fromDeviceAddress = device?.address, - myPeerID = myPeerID - ) - } catch (_: Exception) { } meshCore.processIncoming(packet, peerID, device?.address, ingressLinkID) } @@ -174,6 +168,7 @@ class WearMeshService private constructor(private val context: Context) { peerID: String? ) { Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)") + linkID?.let(initialSyncLinkIDs::remove) try { meshCore.refreshPeerList() } catch (_: Exception) { } if (peerID != null) { meshCore.setDirectConnection(peerID, false) @@ -203,31 +198,6 @@ class WearMeshService private constructor(private val context: Context) { } } - /** - * Proactively establish a Noise session with peers we have no session for (throttled to - * one attempt per peer per 60 s). Peers may hold a stale session after we restart — the - * protocol has no decrypt-failure kick path, so our fresh handshake replaces it and - * restores encrypted DM/file delivery. - */ - private val handshakeAttempts = java.util.concurrent.ConcurrentHashMap() - - private fun maybeAutoHandshake(peerID: String) { - if (peerID == myPeerID || hasEstablishedSession(peerID)) return - val now = System.currentTimeMillis() - val last = handshakeAttempts[peerID] ?: 0L - if (now - last < 60_000) return - handshakeAttempts[peerID] = now - serviceScope.launch { - delay(1_500) - if (!hasEstablishedSession(peerID)) { - try { - Log.d(TAG, "Auto-initiating Noise handshake with ${peerID.take(8)}") - initiateNoiseHandshake(peerID) - } catch (_: Exception) { } - } - } - } - private fun handleMessageReceived( message: com.bitchat.android.model.BitchatMessage ): Boolean = try { @@ -261,7 +231,12 @@ class WearMeshService private constructor(private val context: Context) { // API marks such managers single-use, so build a fresh one instead of starting // a zombie mesh that reports active while scanning nothing. Log.i(TAG, "Recreating BluetoothConnectionManager after terminal stop") - connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) + connectionManager = BluetoothConnectionManager( + context, + myPeerID, + meshCore.fragmentManager, + WearBleRuntimePolicy + ) bleTransport.connectionManager = connectionManager wireBluetoothDelegate() } @@ -282,6 +257,7 @@ class WearMeshService private constructor(private val context: Context) { fun stopServices() { if (!isActive) return isActive = false + initialSyncLinkIDs.clear() meshCore.stopCore() connectionManager.stopServices() Log.i(TAG, "Mesh services stopped") @@ -424,6 +400,8 @@ class WearMeshService private constructor(private val context: Context) { fun getPeerNickname(peerID: String): String? = meshCore.getPeerNickname(peerID) + fun getBlePowerSnapshot() = connectionManager.getPowerSnapshot() + fun getDebugStatus(): String = meshCore.getDebugStatus( transportInfo = connectionManager.getDebugInfo(), deviceMap = connectionManager.addressPeerMap.toMap(), diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index d91f864b..0dd1bd3e 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material.icons.filled.MailOutline import androidx.compose.material.icons.filled.People import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.foundation.lazy.items import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.MaterialTheme @@ -61,16 +61,16 @@ import java.util.Locale @Composable fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { val context = LocalContext.current - val messages by AppStateStore.publicMessages.collectAsState() - val peers by AppStateStore.peers.collectAsState() - val unreadDms by WearChatState.unreadDms.collectAsState() + val messages by AppStateStore.publicMessages.collectAsStateWithLifecycle() + val peers by AppStateStore.peers.collectAsStateWithLifecycle() + val unreadDms by WearChatState.unreadDms.collectAsStateWithLifecycle() val mesh = WearMeshService.peek() val myPeerID = mesh?.myPeerID ?: "" var viewerPath by remember { mutableStateOf(null) } val liveVoiceManager = remember(context) { com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context) } - val busyTalker by liveVoiceManager.activePublicTalker.collectAsState() + val busyTalker by liveVoiceManager.activePublicTalker.collectAsStateWithLifecycle() val voice = rememberVoiceNoteController( recorderFactory = { val target = if ( diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index 1d179a0b..108f0e28 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.material.icons.filled.Verified import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +26,10 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults import androidx.wear.compose.foundation.rotary.rotaryScrollable import androidx.compose.ui.text.font.FontWeight @@ -54,7 +57,7 @@ fun DmScreen( onOpenTextInput: () -> Unit ) { val context = LocalContext.current - val privateMessages by AppStateStore.privateMessages.collectAsState() + val privateMessages by AppStateStore.privateMessages.collectAsStateWithLifecycle() val messages = privateMessages[peerID] ?: emptyList() val mesh = WearMeshService.peek() val myPeerID = mesh?.myPeerID ?: "" @@ -76,7 +79,8 @@ fun DmScreen( ) { path -> mesh?.let { sendVoiceNote(it, peerID, path) } } val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8) - val identityRevision by WearPeerIdentityState.revision.collectAsState() + val identityRevision by WearPeerIdentityState.revision.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current val identity = remember(peerID, identityRevision) { WearPeerIdentityState.snapshot(peerID, mesh) } @@ -94,13 +98,15 @@ fun DmScreen( } } - LaunchedEffect(peerID) { + LaunchedEffect(peerID, lifecycleOwner) { if (mesh?.hasEstablishedSession(peerID) != true) { try { mesh?.initiateNoiseHandshake(peerID) } catch (_: Exception) { } } - while (true) { - sessionEstablished = mesh?.hasEstablishedSession(peerID) == true - kotlinx.coroutines.delay(2_000) + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + sessionEstablished = mesh?.hasEstablishedSession(peerID) == true + kotlinx.coroutines.delay(5_000) + } } } diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt index 1994f976..67c90267 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Verified import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,13 +38,13 @@ import com.bitchat.watch.ui.theme.colorForPeer */ @Composable fun PeerDebugScreen() { - val peers by AppStateStore.peers.collectAsState() + val peers by AppStateStore.peers.collectAsStateWithLifecycle() val mesh = WearMeshService.peek() val listState = rememberScalingLazyListState() val palette = LocalBitchatPalette.current val nicknames = mesh?.getPeerNicknames() ?: emptyMap() val rssi = mesh?.getPeerRSSI() ?: emptyMap() - val identityRevision by WearPeerIdentityState.revision.collectAsState() + val identityRevision by WearPeerIdentityState.revision.collectAsStateWithLifecycle() ScreenScaffold(scrollState = listState) { ScalingLazyColumn( diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt index 3cb86df3..061185c5 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MailOutline import androidx.compose.material.icons.filled.Verified import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -24,6 +23,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState @@ -43,13 +43,13 @@ import com.bitchat.watch.ui.theme.colorForPeer @Composable fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { val context = LocalContext.current - val peers by AppStateStore.peers.collectAsState() - val unread by WearChatState.unreadDms.collectAsState() + val peers by AppStateStore.peers.collectAsStateWithLifecycle() + val unread by WearChatState.unreadDms.collectAsStateWithLifecycle() val mesh = WearMeshService.peek() val listState = rememberScalingLazyListState() val palette = LocalBitchatPalette.current val nicknames = mesh?.getPeerNicknames() ?: emptyMap() - val identityRevision by WearPeerIdentityState.revision.collectAsState() + val identityRevision by WearPeerIdentityState.revision.collectAsStateWithLifecycle() var liveVoiceEnabled by remember { mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt index a36c7ec6..76f79021 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Verified import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,7 +40,7 @@ fun UserDetailScreen( onOpenVerification: () -> Unit ) { val mesh = WearMeshService.peek() - val revision by WearPeerIdentityState.revision.collectAsState() + val revision by WearPeerIdentityState.revision.collectAsStateWithLifecycle() val identity = androidx.compose.runtime.remember(peerID, revision) { WearPeerIdentityState.snapshot(peerID, mesh) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt index c4c4ef0f..5c482beb 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -10,7 +10,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.outlined.Warning import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,7 +35,7 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette @Composable fun VerificationCodeScreen(peerID: String) { val mesh = WearMeshService.peek() - val revision by WearPeerIdentityState.revision.collectAsState() + val revision by WearPeerIdentityState.revision.collectAsStateWithLifecycle() val identity = androidx.compose.runtime.remember(peerID, revision) { WearPeerIdentityState.snapshot(peerID, mesh) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt index 7ec162af..d9cfe7bf 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -2,6 +2,7 @@ package com.bitchat.watch.ui import android.content.Context import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -9,6 +10,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.bitchat.android.features.voice.VoiceRecorder import com.bitchat.android.features.voice.normalizeAmplitudeSample import kotlinx.coroutines.CoroutineScope @@ -99,5 +103,19 @@ fun rememberVoiceNoteController( ): VoiceNoteController { val context = LocalContext.current val scope = rememberCoroutineScope() - return remember { VoiceNoteController(context, scope, recorderFactory, onSendVoice) } + val lifecycleOwner = LocalLifecycleOwner.current + val controller = remember { + VoiceNoteController(context, scope, recorderFactory, onSendVoice) + } + DisposableEffect(lifecycleOwner, controller) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) controller.stop(send = false) + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + controller.stop(send = false) + } + } + return controller } diff --git a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt index e27f132e..f168ea36 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -26,7 +26,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -130,13 +133,14 @@ fun VoiceNoteItem(path: String, messageID: String? = null) { val palette = LocalBitchatPalette.current val context = LocalContext.current val liveIDs by com.bitchat.android.features.voice.LiveVoiceManager - .getInstance(context).liveMessageIDs.collectAsState() + .getInstance(context).liveMessageIDs.collectAsStateWithLifecycle() val isLive = messageID != null && messageID in liveIDs var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) } var playing by remember { mutableStateOf(false) } var progress by remember { mutableFloatStateOf(0f) } var durationMs by remember { mutableIntStateOf(0) } val player = remember { MediaPlayer() } + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(path) { if (samples == null) { @@ -166,10 +170,12 @@ fun VoiceNoteItem(path: String, messageID: String? = null) { } } - LaunchedEffect(playing) { - while (playing) { - progress = if (durationMs > 0) player.currentPosition.toFloat() / durationMs else 0f - delay(100) + LaunchedEffect(playing, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (playing) { + progress = if (durationMs > 0) player.currentPosition.toFloat() / durationMs else 0f + delay(100) + } } } diff --git a/wear/src/test/java/com/bitchat/watch/mesh/WearBleRuntimePolicyTest.kt b/wear/src/test/java/com/bitchat/watch/mesh/WearBleRuntimePolicyTest.kt new file mode 100644 index 00000000..eed6473f --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/mesh/WearBleRuntimePolicyTest.kt @@ -0,0 +1,92 @@ +package com.bitchat.watch.mesh + +import android.bluetooth.BluetoothGatt +import com.bitchat.android.mesh.BleConnectionLimits +import com.bitchat.android.mesh.PowerManager +import com.bitchat.android.mesh.PowerProfileResolver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearBleRuntimePolicyTest { + @Test + fun `connection limits can be lowered but never raised above two`() { + assertEquals( + BleConnectionLimits(2, 2, 2), + WearBleRuntimePolicy.connectionLimits(BleConnectionLimits(8, 8, 8)) + ) + assertEquals( + BleConnectionLimits(1, 2, 2), + WearBleRuntimePolicy.connectionLimits(BleConnectionLimits(1, 4, 4)) + ) + } + + @Test + fun `two links disable both discovery and advertising`() { + val profile = profile(background = true) + + assertFalse(WearBleRuntimePolicy.scanPlan(profile, 2).enabled) + assertFalse(WearBleRuntimePolicy.shouldAdvertise(profile, 2)) + } + + @Test + fun `one background link scans for one second every five minutes`() { + val plan = WearBleRuntimePolicy.scanPlan(profile(background = true), 1) + + assertTrue(plan.enabled) + assertFalse(plan.continuous) + assertEquals(1_000L, plan.scanOnMs) + assertEquals(299_000L, plan.scanOffMs) + } + + @Test + fun `disconnected background discovery slows with battery pressure`() { + val normal = WearBleRuntimePolicy.scanPlan(profile(background = true, battery = 80), 0) + val low = WearBleRuntimePolicy.scanPlan(profile(background = true, battery = 20), 0) + val critical = WearBleRuntimePolicy.scanPlan(profile(background = true, battery = 10), 0) + + assertEquals(59_000L, normal.scanOffMs) + assertEquals(119_000L, low.scanOffMs) + assertEquals(299_000L, critical.scanOffMs) + } + + @Test + fun `foreground discovery remains responsive but never continuous`() { + val plan = WearBleRuntimePolicy.scanPlan( + PowerProfileResolver.resolve(80, true, false, false), + activeConnections = 1 + ) + + assertEquals(PowerManager.PowerMode.PERFORMANCE, profile(false, charging = true).mode) + assertTrue(plan.enabled) + assertFalse(plan.continuous) + assertEquals(8_000L, plan.scanOnMs) + assertEquals(2_000L, plan.scanOffMs) + } + + @Test + fun `RSSI polling is disabled only in the background`() { + assertFalse(WearBleRuntimePolicy.shouldPollRssi(profile(background = true))) + assertTrue(WearBleRuntimePolicy.shouldPollRssi(profile(background = false))) + } + + @Test + fun `bulk transfers temporarily request high connection priority`() { + assertEquals( + BluetoothGatt.CONNECTION_PRIORITY_HIGH, + WearBleRuntimePolicy.transferGattConnectionPriority() + ) + } + + private fun profile( + background: Boolean, + battery: Int = 80, + charging: Boolean = false + ) = PowerProfileResolver.resolve( + batteryLevel = battery, + isCharging = charging, + isBackground = background, + hasDirectPeers = false + ) +}