Optimize Wear mesh power usage

This commit is contained in:
callebtc 2026-08-01 12:50:27 +02:00
parent b8c7470ace
commit e2387ac4a6
26 changed files with 1086 additions and 177 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, ConnectionAttempt>()
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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Boolean> {
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<BluetoothDevice>()
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<BluetoothDevice>()
whenever(device.address).thenReturn(address)
tracker.addDeviceConnection(
address,
BluetoothConnectionTracker.DeviceConnection(
device = device,
isClient = isClient,
linkID = "link-$address"
)
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String>()
@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<String, Long>()
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(),

View File

@ -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<String?>(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 (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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