mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Optimize Wear mesh power usage
This commit is contained in:
parent
b8c7470ace
commit
e2387ac4a6
@ -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
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user