From dd8aa083b9db415a9394d6f04029250543aa5f67 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:47:54 +0200 Subject: [PATCH] power saving --- .../java/com/bitchat/android/MainActivity.kt | 15 + .../mesh/BluetoothConnectionManager.kt | 667 +++++++++--------- .../android/mesh/BluetoothMeshService.kt | 2 +- .../com/bitchat/android/mesh/PowerManager.kt | 337 +++++++++ .../com/bitchat/android/ui/ChatViewModel.kt | 5 + 5 files changed, 675 insertions(+), 351 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/PowerManager.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 30a8f2fb..4d7091e6 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -15,9 +15,12 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.lifecycleScope import com.bitchat.android.ui.ChatScreen import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.theme.BitchatTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -53,6 +56,18 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + // Notify that app is in foreground for power optimization + chatViewModel.setAppBackgroundState(false) + } + + override fun onPause() { + super.onPause() + // Notify that app is in background for power optimization + chatViewModel.setAppBackgroundState(true) + } + private fun requestPermissions() { val permissions = mutableListOf() diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 7a951880..efa8dada 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -15,45 +15,52 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList /** - * Manages Bluetooth connections, advertising, and scanning - * Extracted from BluetoothMeshService for better separation of concerns + * Power-optimized Bluetooth connection manager with comprehensive memory management + * Integrates with PowerManager for adaptive power consumption */ -class BluetoothConnectionManager(private val context: Context, private val myPeerID: String) { +class BluetoothConnectionManager( + private val context: Context, + private val myPeerID: String +) : PowerManagerDelegate { companion object { private const val TAG = "BluetoothConnectionManager" - // Use exact same UUIDs as iOS version and original service + // Use exact same UUIDs as iOS version private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") + + // Connection management constants + private const val CONNECTION_RETRY_DELAY = 5000L + private const val MAX_CONNECTION_ATTEMPTS = 3 + private const val CLEANUP_DELAY = 500L + private const val CLEANUP_INTERVAL = 30000L // 30 seconds } // Core Bluetooth components - private val bluetoothManager: BluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothManager: BluetoothManager = + context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner private val bleAdvertiser: BluetoothLeAdvertiser? = bluetoothAdapter?.bluetoothLeAdvertiser + // Power management + private val powerManager = PowerManager(context) + // GATT server for peripheral mode private var gattServer: BluetoothGattServer? = null private var characteristic: BluetoothGattCharacteristic? = null - // Connection tracking - FIXED to properly track both server and client connections - private val connectedDevices = ConcurrentHashMap() - private val deviceCharacteristics = ConcurrentHashMap() + // Simplified connection tracking - reduced memory footprint + private val connectedDevices = ConcurrentHashMap() private val subscribedDevices = CopyOnWriteArrayList() - private val gattConnections = ConcurrentHashMap() // Track GATT client connections - private val peripheralRSSI = ConcurrentHashMap() // Track RSSI by device address during discovery - // FIXED: Track connection attempts to prevent duplicates - private val pendingConnections = ConcurrentHashMap() // device address -> attempt timestamp - private val connectionAttempts = ConcurrentHashMap() // device address -> attempt count - private val maxConnectionAttempts = 3 - private val connectionRetryDelay = 5000L // 5 seconds + // Connection attempt tracking with automatic cleanup + private val pendingConnections = ConcurrentHashMap() - // Service registration tracking - private var serviceRegistered = false - private var advertisingActive = false - private var scanningActive = false + // Service state + private var isActive = false + private var scanCallback: ScanCallback? = null + private var advertiseCallback: AdvertiseCallback? = null // Delegate for callbacks var delegate: BluetoothConnectionManagerDelegate? = null @@ -62,10 +69,41 @@ class BluetoothConnectionManager(private val context: Context, private val myPee private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) /** - * Start all Bluetooth services + * Consolidated device connection information + */ + private data class DeviceConnection( + val device: BluetoothDevice, + val gatt: BluetoothGatt? = null, + val characteristic: BluetoothGattCharacteristic? = null, + val rssi: Int = Int.MIN_VALUE, + val isClient: Boolean = false, + val connectedAt: Long = System.currentTimeMillis() + ) + + /** + * Connection attempt tracking with automatic expiry + */ + private data class ConnectionAttempt( + val attempts: Int, + val lastAttempt: Long = System.currentTimeMillis() + ) { + fun isExpired(): Boolean = + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 + + fun shouldRetry(): Boolean = + attempts < MAX_CONNECTION_ATTEMPTS && + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + } + + init { + powerManager.delegate = this + } + + /** + * Start all Bluetooth services with power optimization */ fun startServices(): Boolean { - Log.i(TAG, "Starting Bluetooth services...") + Log.i(TAG, "Starting power-optimized Bluetooth services...") if (!hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") @@ -83,19 +121,26 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } try { + isActive = true setupGattServer() - // Start services in sequence + // Start power manager and services connectionScope.launch { + powerManager.start() delay(500) // Ensure GATT server is ready startAdvertising() delay(200) - startScanning() - delay(200) + if (powerManager.shouldUseDutyCycle()) { + Log.i(TAG, "Using power-aware duty cycling") + } else { + startScanning() + } - Log.i(TAG, "All Bluetooth services started successfully") + startPeriodicCleanup() + + Log.i(TAG, "Power-optimized Bluetooth services started successfully") } return true @@ -107,82 +152,85 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } /** - * Stop all Bluetooth services + * Stop all Bluetooth services with proper cleanup */ fun stopServices() { - Log.i(TAG, "Stopping Bluetooth services") + Log.i(TAG, "Stopping power-optimized Bluetooth services") + + isActive = false connectionScope.launch { - // Cleanup all GATT client connections - gattConnections.values.forEach { gatt -> - try { - gatt.disconnect() - gatt.close() - } catch (e: Exception) { - Log.w(TAG, "Error closing GATT connection: ${e.message}") - } - } + // Stop power manager first + powerManager.stop() - // Stop advertising and scanning - stopAdvertising() + // Stop scanning and advertising stopScanning() + stopAdvertising() + + // Cleanup all GATT connections with delay + cleanupAllConnections() // Close GATT server gattServer?.close() + gattServer = null - // Clear all connection tracking - connectedDevices.clear() - deviceCharacteristics.clear() - subscribedDevices.clear() - gattConnections.clear() - peripheralRSSI.clear() + // Clear tracking + clearAllConnections() connectionScope.cancel() + + Log.i(TAG, "All Bluetooth services stopped") } } /** - * Broadcast packet to all connected devices + * Set app background state for power optimization + */ + fun setAppBackgroundState(inBackground: Boolean) { + powerManager.setAppBackgroundState(inBackground) + } + + /** + * Broadcast packet to connected devices with connection limit enforcement */ fun broadcastPacket(packet: BitchatPacket) { + if (!isActive) return + val data = packet.toBinaryData() ?: return - Log.d(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server connections and ${gattConnections.size} client connections") + Log.d(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections") - // Send to devices connected to our GATT server + // Send to server connections (devices connected to our GATT server) subscribedDevices.forEach { device -> try { characteristic?.let { char -> char.value = data - val success = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false - if (success) { - Log.d(TAG, "Sent packet to server connection: ${device.address}") - } else { - Log.w(TAG, "Failed to send packet to server connection: ${device.address}") - } + gattServer?.notifyCharacteristicChanged(device, char, false) } } catch (e: Exception) { - Log.e(TAG, "Error sending to server connection ${device.address}: ${e.message}") + Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}") + // Clean up failed connection + connectionScope.launch { + delay(CLEANUP_DELAY) + subscribedDevices.remove(device) + } } } - // Send to devices we are connected to as a client - gattConnections.forEach { (device, gatt) -> - try { - val characteristic = deviceCharacteristics[device] - if (characteristic != null) { - characteristic.value = data - val success = gatt.writeCharacteristic(characteristic) - if (success) { - Log.d(TAG, "Sent packet to client connection: ${device.address}") - } else { - Log.w(TAG, "Failed to send packet to client connection: ${device.address}") + // Send to client connections + connectedDevices.values.forEach { deviceConn -> + if (deviceConn.isClient && deviceConn.gatt != null && deviceConn.characteristic != null) { + try { + deviceConn.characteristic.value = data + deviceConn.gatt.writeCharacteristic(deviceConn.characteristic) + } catch (e: Exception) { + Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}") + // Clean up failed connection + connectionScope.launch { + delay(CLEANUP_DELAY) + cleanupDeviceConnection(deviceConn.device.address) } - } else { - Log.w(TAG, "No characteristic found for client connection: ${device.address}") } - } catch (e: Exception) { - Log.e(TAG, "Error sending to client connection ${device.address}: ${e.message}") } } } @@ -190,61 +238,71 @@ class BluetoothConnectionManager(private val context: Context, private val myPee /** * Get connected device count */ - fun getConnectedDeviceCount(): Int { - return connectedDevices.size - } + fun getConnectedDeviceCount(): Int = connectedDevices.size /** - * Get debug information + * Get debug information including power management */ fun getDebugInfo(): String { return buildString { - appendLine("=== Bluetooth Connection Manager Debug Info ===") + appendLine("=== Power-Optimized Bluetooth Connection Manager ===") + appendLine("Active: $isActive") appendLine("Bluetooth Enabled: ${bluetoothAdapter?.isEnabled}") appendLine("Has Permissions: ${hasBluetoothPermissions()}") - appendLine("BLE Scanner Available: ${bleScanner != null}") - appendLine("BLE Advertiser Available: ${bleAdvertiser != null}") appendLine("GATT Server Active: ${gattServer != null}") - appendLine("Service Registered: $serviceRegistered") - appendLine("Advertising Active: $advertisingActive") - appendLine("Scanning Active: $scanningActive") appendLine() - appendLine("Connected Devices: ${connectedDevices.size}") - connectedDevices.forEach { (address, device) -> - appendLine(" - $address") - } + appendLine(powerManager.getPowerInfo()) appendLine() - appendLine("GATT Client Connections: ${gattConnections.size}") - gattConnections.keys.forEach { device -> - appendLine(" - ${device.address}") + appendLine("Connected Devices: ${connectedDevices.size} / ${powerManager.getMaxConnections()}") + connectedDevices.forEach { (address, deviceConn) -> + val age = (System.currentTimeMillis() - deviceConn.connectedAt) / 1000 + appendLine(" - $address (${if (deviceConn.isClient) "client" else "server"}, ${age}s, RSSI: ${deviceConn.rssi})") } appendLine() appendLine("Subscribed Devices (server mode): ${subscribedDevices.size}") - subscribedDevices.forEach { device -> - appendLine(" - ${device.address}") - } appendLine() appendLine("Pending Connections: ${pendingConnections.size}") - pendingConnections.forEach { (address, timestamp) -> - val elapsed = (System.currentTimeMillis() - timestamp) / 1000 - appendLine(" - $address (${elapsed}s ago)") - } - appendLine() - appendLine("Connection Attempts: ${connectionAttempts.size}") - connectionAttempts.forEach { (address, attempts) -> - appendLine(" - $address: $attempts attempts") - } - appendLine() - appendLine("Peripheral RSSI: ${peripheralRSSI.size}") - peripheralRSSI.forEach { (address, rssi) -> - appendLine(" - $address: $rssi dBm") + val now = System.currentTimeMillis() + pendingConnections.forEach { (address, attempt) -> + val elapsed = (now - attempt.lastAttempt) / 1000 + appendLine(" - $address: ${attempt.attempts} attempts, last ${elapsed}s ago") } } } - /** - * Check if we have the required Bluetooth permissions - */ + // MARK: - PowerManagerDelegate Implementation + + override fun onPowerModeChanged(newMode: PowerManager.PowerMode) { + Log.i(TAG, "Power mode changed to: $newMode") + + connectionScope.launch { + // Update advertising and scanning based on new power mode + stopAdvertising() + delay(100) + startAdvertising() + + // Restart scanning with new settings if not using duty cycle + if (!powerManager.shouldUseDutyCycle()) { + stopScanning() + delay(100) + startScanning() + } + + // Enforce connection limits + enforceConnectionLimits() + } + } + + override fun onScanStateChanged(shouldScan: Boolean) { + if (shouldScan) { + startScanning() + } else { + stopScanning() + } + } + + // MARK: - Private Implementation + private fun hasBluetoothPermissions(): Boolean { val permissions = mutableListOf() @@ -271,9 +329,6 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } } - /** - * Setup GATT server for peripheral mode - */ @Suppress("DEPRECATION") private fun setupGattServer() { if (!hasBluetoothPermissions()) return @@ -282,14 +337,16 @@ class BluetoothConnectionManager(private val context: Context, private val myPee override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { when (newState) { BluetoothProfile.STATE_CONNECTED -> { - Log.d(TAG, "Device connected to server: ${device.address}") - connectedDevices[device.address] = device + Log.d(TAG, "Server: Device connected ${device.address}") + val deviceConn = DeviceConnection( + device = device, + isClient = false + ) + connectedDevices[device.address] = deviceConn } BluetoothProfile.STATE_DISCONNECTED -> { - Log.d(TAG, "Device disconnected from server: ${device.address}") - connectedDevices.remove(device.address) - deviceCharacteristics.remove(device) - subscribedDevices.remove(device) + Log.d(TAG, "Server: Device disconnected ${device.address}") + cleanupDeviceConnection(device.address) } } } @@ -304,8 +361,6 @@ class BluetoothConnectionManager(private val context: Context, private val myPee value: ByteArray ) { if (characteristic.uuid == CHARACTERISTIC_UUID) { - Log.d(TAG, "Received write request from ${device.address}, ${value.size} bytes") - val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { val peerID = String(packet.senderID).replace("\u0000", "") @@ -329,14 +384,11 @@ class BluetoothConnectionManager(private val context: Context, private val myPee ) { if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) { Log.d(TAG, "Device ${device.address} subscribed to notifications") - if (!subscribedDevices.contains(device)) { - subscribedDevices.add(device) - - // Notify delegate about new connection - connectionScope.launch { - delay(100) // Ensure connection is stable - delegate?.onDeviceConnected(device) - } + subscribedDevices.add(device) + + connectionScope.launch { + delay(100) + delegate?.onDeviceConnected(device) } } @@ -346,13 +398,12 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } } - // Clean up any existing GATT server + // Clean up existing server gattServer?.close() - clearAllConnections() gattServer = bluetoothManager.openGattServer(context, serverCallback) - // Create characteristic + // Create characteristic with notification support characteristic = BluetoothGattCharacteristic( CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_READ or @@ -363,14 +414,12 @@ class BluetoothConnectionManager(private val context: Context, private val myPee BluetoothGattCharacteristic.PERMISSION_WRITE ) - // Add notification descriptor val descriptor = BluetoothGattDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE ) characteristic?.addDescriptor(descriptor) - // Create service val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY) service.addCharacteristic(characteristic) @@ -379,49 +428,25 @@ class BluetoothConnectionManager(private val context: Context, private val myPee Log.i(TAG, "GATT server setup complete") } - /** - * Start BLE advertising - */ @Suppress("DEPRECATION") private fun startAdvertising() { - if (!hasBluetoothPermissions() || bleAdvertiser == null) { - Log.e(TAG, "Cannot start advertising: missing permissions or advertiser unavailable") - return - } + if (!hasBluetoothPermissions() || bleAdvertiser == null || !isActive) return - val settings = AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) - .setConnectable(true) - .setTimeout(0) - .build() + val settings = powerManager.getAdvertiseSettings() val data = AdvertiseData.Builder() .addServiceUuid(ParcelUuid(SERVICE_UUID)) - .addServiceData(ParcelUuid(SERVICE_UUID), myPeerID.toByteArray(Charsets.UTF_8)) .setIncludeTxPowerLevel(false) .setIncludeDeviceName(false) .build() - val advertiseCallback = object : AdvertiseCallback() { + advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { - Log.i(TAG, "Advertising started successfully with peer ID: $myPeerID") + Log.i(TAG, "Advertising started (power mode: ${powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]})") } override fun onStartFailure(errorCode: Int) { - val errorMessage = when (errorCode) { - ADVERTISE_FAILED_ALREADY_STARTED -> "Already started" - ADVERTISE_FAILED_DATA_TOO_LARGE -> "Data too large" - ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported" - ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error" - ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers" - else -> "Unknown error: $errorCode" - } - Log.e(TAG, "Advertising failed: $errorMessage") - - if (errorCode == ADVERTISE_FAILED_DATA_TOO_LARGE) { - startMinimalAdvertising() - } + Log.e(TAG, "Advertising failed: $errorCode") } } @@ -432,75 +457,25 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } } - /** - * Fallback minimal advertising - */ - @Suppress("DEPRECATION") - private fun startMinimalAdvertising() { - if (!hasBluetoothPermissions() || bleAdvertiser == null) return - - val settings = AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) - .setConnectable(true) - .setTimeout(0) - .build() - - val data = AdvertiseData.Builder() - .setIncludeDeviceName(false) - .setIncludeTxPowerLevel(false) - .addServiceUuid(ParcelUuid(SERVICE_UUID)) - .build() - - val advertiseCallback = object : AdvertiseCallback() { - override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { - Log.i(TAG, "Minimal advertising started successfully") - } - - override fun onStartFailure(errorCode: Int) { - Log.e(TAG, "Even minimal advertising failed: $errorCode") - } - } - - try { - bleAdvertiser.startAdvertising(settings, data, advertiseCallback) - } catch (e: Exception) { - Log.e(TAG, "Exception starting minimal advertising: ${e.message}") - } - } - - /** - * Stop BLE advertising - */ @Suppress("DEPRECATION") private fun stopAdvertising() { if (!hasBluetoothPermissions() || bleAdvertiser == null) return - bleAdvertiser.stopAdvertising(object : AdvertiseCallback() {}) + try { + advertiseCallback?.let { bleAdvertiser.stopAdvertising(it) } + } catch (e: Exception) { + Log.w(TAG, "Error stopping advertising: ${e.message}") + } } - /** - * Start BLE scanning - */ @Suppress("DEPRECATION") private fun startScanning() { - if (!hasBluetoothPermissions() || bleScanner == null) { - Log.e(TAG, "Cannot start scanning: missing permissions or scanner unavailable") - return - } + if (!hasBluetoothPermissions() || bleScanner == null || !isActive) return val scanFilter = ScanFilter.Builder() .setServiceUuid(ParcelUuid(SERVICE_UUID)) .build() - val scanSettings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) - .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) - .setReportDelay(10) - .build() - - val scanCallback = object : ScanCallback() { + scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { handleScanResult(result) } @@ -510,248 +485,240 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } override fun onScanFailed(errorCode: Int) { - val errorMessage = when (errorCode) { - SCAN_FAILED_ALREADY_STARTED -> "Already started" - SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed" - SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported" - SCAN_FAILED_INTERNAL_ERROR -> "Internal error" - else -> "Unknown error: $errorCode" - } - Log.e(TAG, "Scan failed: $errorMessage") + Log.e(TAG, "Scan failed: $errorCode") } } try { - bleScanner.startScan(listOf(scanFilter), scanSettings, scanCallback) - Log.i(TAG, "Started BLE scanning") + bleScanner.startScan(listOf(scanFilter), powerManager.getScanSettings(), scanCallback) } catch (e: Exception) { Log.e(TAG, "Exception starting scan: ${e.message}") } } - /** - * Stop BLE scanning - */ @Suppress("DEPRECATION") private fun stopScanning() { if (!hasBluetoothPermissions() || bleScanner == null) return - bleScanner.stopScan(object : ScanCallback() {}) + try { + scanCallback?.let { bleScanner.stopScan(it) } + } catch (e: Exception) { + Log.w(TAG, "Error stopping scan: ${e.message}") + } } - /** - * Handle scan result and connect to discovered devices - FIXED to prevent duplicate connections - */ - @Suppress("DEPRECATION") private fun handleScanResult(result: ScanResult) { val device = result.device val rssi = result.rssi val deviceAddress = device.address - // Filter out weak signals - if (rssi < -90) { + // Power-aware RSSI filtering + if (rssi < powerManager.getRSSIThreshold()) { return } - // FIXED: Check for existing client connections only, allow server connections - if (gattConnections.containsKey(device)) { - Log.d(TAG, "Already have client connection to $deviceAddress") + // Check if already connected or at connection limit + if (connectedDevices.containsKey(deviceAddress)) { return } - // FIXED: Check for pending connections to prevent duplicates + if (connectedDevices.size >= powerManager.getMaxConnections()) { + Log.d(TAG, "Connection limit reached (${powerManager.getMaxConnections()})") + return + } + + // Check connection attempts val currentTime = System.currentTimeMillis() - val pendingTime = pendingConnections[deviceAddress] - if (pendingTime != null && (currentTime - pendingTime) < connectionRetryDelay) { - Log.d(TAG, "Connection attempt to $deviceAddress already pending, skipping") - return - } + val existingAttempt = pendingConnections[deviceAddress] - // Check connection attempt limits - val attempts = connectionAttempts.getOrDefault(deviceAddress, 0) - if (attempts >= maxConnectionAttempts) { - if (pendingTime == null || (currentTime - pendingTime) > connectionRetryDelay * 2) { - // Reset after extended delay - connectionAttempts[deviceAddress] = 0 + if (existingAttempt != null) { + if (existingAttempt.isExpired()) { pendingConnections.remove(deviceAddress) - Log.d(TAG, "Reset connection attempts for $deviceAddress after extended delay") - } else { - Log.d(TAG, "Max connection attempts reached for $deviceAddress") + } else if (!existingAttempt.shouldRetry()) { return } } - // Store RSSI and track connection attempt - peripheralRSSI[deviceAddress] = rssi - pendingConnections[deviceAddress] = currentTime - connectionAttempts[deviceAddress] = attempts + 1 + // Update connection attempt + val attempts = (existingAttempt?.attempts ?: 0) + 1 + pendingConnections[deviceAddress] = ConnectionAttempt(attempts) - Log.i(TAG, "Found bitchat service at $deviceAddress (RSSI: $rssi), connecting (attempt ${attempts + 1})...") + Log.i(TAG, "Connecting to $deviceAddress (RSSI: $rssi, attempt: $attempts)") - // Connect to device - connectToDeviceFixed(device) + connectToDevice(device, rssi) } - /** - * Connect to a discovered device - FIXED version with proper timeout and cleanup handling - */ @Suppress("DEPRECATION") - private fun connectToDeviceFixed(device: BluetoothDevice) { + private fun connectToDevice(device: BluetoothDevice, rssi: Int) { if (!hasBluetoothPermissions()) return val deviceAddress = device.address - Log.d(TAG, "Attempting to connect to $deviceAddress") val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - val statusMessage = when (status) { - BluetoothGatt.GATT_SUCCESS -> "SUCCESS" - 147 -> "CONNECTION_TIMEOUT" - 133 -> "GATT_ERROR" - 8 -> "CONNECTION_TIMEOUT" - else -> "ERROR_$status" - } - - Log.d(TAG, "onConnectionStateChange: $deviceAddress status=$statusMessage($status) newState=$newState") - when (newState) { BluetoothProfile.STATE_CONNECTED -> { if (status == BluetoothGatt.GATT_SUCCESS) { - Log.i(TAG, "Successfully connected to $deviceAddress as client") - connectedDevices[deviceAddress] = gatt.device - gattConnections[gatt.device] = gatt - pendingConnections.remove(deviceAddress) // Clear pending connection + Log.i(TAG, "Client: Connected to $deviceAddress") + val deviceConn = DeviceConnection( + device = device, + gatt = gatt, + rssi = rssi, + isClient = true + ) + connectedDevices[deviceAddress] = deviceConn + pendingConnections.remove(deviceAddress) - // Discover services with a slight delay for stability connectionScope.launch { - delay(200) // Small delay for connection stabilization - if (gattConnections.containsKey(gatt.device)) { - gatt.discoverServices() - } + delay(200) + gatt.discoverServices() } } else { - Log.e(TAG, "Connected to $deviceAddress with error status: $status") gatt.disconnect() } } BluetoothProfile.STATE_DISCONNECTED -> { - Log.d(TAG, "Disconnected from $deviceAddress (status: $status)") - connectedDevices.remove(deviceAddress) - deviceCharacteristics.remove(gatt.device) - gattConnections.remove(gatt.device) - pendingConnections.remove(deviceAddress) // Clear pending connection + Log.d(TAG, "Client: Disconnected from $deviceAddress") + cleanupDeviceConnection(deviceAddress) - // Handle connection errors - if (status == 147 || status == 133 || status == 8) { // Timeout or connection errors - Log.w(TAG, "Connection failed to $deviceAddress with status $status") - } - - // Close the GATT connection - try { - gatt.close() - } catch (e: Exception) { - Log.e(TAG, "Error closing GATT connection to $deviceAddress: ${e.message}") + connectionScope.launch { + delay(CLEANUP_DELAY) + try { + gatt.close() + } catch (e: Exception) { + Log.w(TAG, "Error closing GATT: ${e.message}") + } } } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - Log.d(TAG, "onServicesDiscovered: $deviceAddress status=$status") - if (status == BluetoothGatt.GATT_SUCCESS) { val service = gatt.getService(SERVICE_UUID) val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID) if (characteristic != null) { - Log.d(TAG, "Found bitchat service and characteristic on $deviceAddress") - deviceCharacteristics[gatt.device] = characteristic + // Update device connection with characteristic + connectedDevices[deviceAddress]?.let { deviceConn -> + val updatedConn = deviceConn.copy(characteristic = characteristic) + connectedDevices[deviceAddress] = updatedConn + } // Enable notifications - val notificationSuccess = gatt.setCharacteristicNotification(characteristic, true) - Log.d(TAG, "Set notification for $deviceAddress: $notificationSuccess") + gatt.setCharacteristicNotification(characteristic, true) - // Write notification descriptor val descriptor = characteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") ) - if (descriptor != null) { - descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - val descriptorSuccess = gatt.writeDescriptor(descriptor) - Log.d(TAG, "Write notification descriptor for $deviceAddress: $descriptorSuccess") + descriptor?.let { + it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(it) } - // Notify delegate about successful connection connectionScope.launch { - delay(100) // Ensure everything is set up - delegate?.onDeviceConnected(gatt.device) + delay(100) + delegate?.onDeviceConnected(device) } } else { - Log.e(TAG, "Bitchat service or characteristic not found on $deviceAddress") gatt.disconnect() } } else { - Log.e(TAG, "Service discovery failed on $deviceAddress with status: $status") gatt.disconnect() } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val value = characteristic.value - Log.d(TAG, "Received ${value.size} bytes from $deviceAddress") - val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { val peerID = String(packet.senderID).replace("\u0000", "") delegate?.onPacketReceived(packet, peerID, gatt.device) - } else { - Log.w(TAG, "Failed to parse packet from $deviceAddress") - } - } - - override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { - Log.d(TAG, "onDescriptorWrite: $deviceAddress status=$status") - if (status == BluetoothGatt.GATT_SUCCESS) { - Log.i(TAG, "Successfully enabled notifications for $deviceAddress") - } else { - Log.e(TAG, "Failed to enable notifications for $deviceAddress: $status") } } } - // Attempt the connection try { - val gatt = device.connectGatt(context, false, gattCallback) - if (gatt == null) { - Log.e(TAG, "Failed to create GATT connection to $deviceAddress") - pendingConnections.remove(deviceAddress) - } + device.connectGatt(context, false, gattCallback) } catch (e: Exception) { Log.e(TAG, "Exception connecting to $deviceAddress: ${e.message}") pendingConnections.remove(deviceAddress) } } - /** - * Connect to a discovered device - LEGACY METHOD (kept for compatibility) - */ - @Suppress("DEPRECATION") - private fun connectToDevice(device: BluetoothDevice) { - // Redirect to fixed method - connectToDeviceFixed(device) + private fun startPeriodicCleanup() { + connectionScope.launch { + while (isActive) { + delay(CLEANUP_INTERVAL) + + if (!isActive) break + + try { + // Clean up expired pending connections + val expiredConnections = pendingConnections.filter { it.value.isExpired() } + expiredConnections.keys.forEach { pendingConnections.remove(it) } + + // Log cleanup if any + if (expiredConnections.isNotEmpty()) { + Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts") + } + + // Log current state + Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending") + + } catch (e: Exception) { + Log.w(TAG, "Error in periodic cleanup: ${e.message}") + } + } + } + } + + private fun enforceConnectionLimits() { + val maxConnections = powerManager.getMaxConnections() + if (connectedDevices.size > maxConnections) { + Log.i(TAG, "Enforcing connection limit: ${connectedDevices.size} > $maxConnections") + + // Disconnect oldest client connections first + val sortedConnections = connectedDevices.values + .filter { it.isClient } + .sortedBy { it.connectedAt } + + val toDisconnect = sortedConnections.take(connectedDevices.size - maxConnections) + toDisconnect.forEach { deviceConn -> + Log.d(TAG, "Disconnecting ${deviceConn.device.address} due to connection limit") + deviceConn.gatt?.disconnect() + } + } + } + + private fun cleanupDeviceConnection(deviceAddress: String) { + connectedDevices.remove(deviceAddress)?.let { deviceConn -> + subscribedDevices.removeAll { it.address == deviceAddress } + } + pendingConnections.remove(deviceAddress) + } + + private fun cleanupAllConnections() { + connectedDevices.values.forEach { deviceConn -> + deviceConn.gatt?.disconnect() + } + + connectionScope.launch { + delay(CLEANUP_DELAY) + + connectedDevices.values.forEach { deviceConn -> + try { + deviceConn.gatt?.close() + } catch (e: Exception) { + Log.w(TAG, "Error closing GATT during cleanup: ${e.message}") + } + } + } } - /** - * Clear all connection tracking - */ private fun clearAllConnections() { connectedDevices.clear() - deviceCharacteristics.clear() subscribedDevices.clear() - gattConnections.clear() - peripheralRSSI.clear() - // FIXED: Clear new tracking maps pendingConnections.clear() - connectionAttempts.clear() } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index b6a9a9ff..0c185686 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -44,7 +44,7 @@ class BluetoothMeshService(private val context: Context) { private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() private val messageHandler = MessageHandler(myPeerID) - private val connectionManager = BluetoothConnectionManager(context, myPeerID) + internal val connectionManager = BluetoothConnectionManager(context, myPeerID) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) // Delegate for message callbacks (maintains same interface) diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt new file mode 100644 index 00000000..69358e23 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -0,0 +1,337 @@ +package com.bitchat.android.mesh + +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.ScanSettings +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import android.util.Log +import kotlinx.coroutines.* +import kotlin.math.max + +/** + * Power-aware Bluetooth management for bitchat + * Adjusts scanning, advertising, and connection behavior based on battery state + */ +class PowerManager(private val context: Context) { + + companion object { + private const val TAG = "PowerManager" + + // Battery thresholds + private const val CRITICAL_BATTERY = 10 + private const val LOW_BATTERY = 20 + private const val MEDIUM_BATTERY = 50 + + // Scan duty cycle periods (ms) + private const val SCAN_ON_DURATION_NORMAL = 2000L // 2 seconds on + private const val SCAN_OFF_DURATION_NORMAL = 8000L // 8 seconds off + private const val SCAN_ON_DURATION_POWER_SAVE = 1000L // 1 second on + private const val SCAN_OFF_DURATION_POWER_SAVE = 15000L // 15 seconds off + private const val SCAN_ON_DURATION_ULTRA_LOW = 500L // 0.5 seconds on + private const val SCAN_OFF_DURATION_ULTRA_LOW = 30000L // 30 seconds off + + // Connection limits + private const val MAX_CONNECTIONS_NORMAL = 8 + private const val MAX_CONNECTIONS_POWER_SAVE = 4 + private const val MAX_CONNECTIONS_ULTRA_LOW = 2 + } + + enum class PowerMode { + PERFORMANCE, // Full power, no restrictions + BALANCED, // Moderate power saving + POWER_SAVER, // Aggressive power saving + ULTRA_LOW_POWER // Minimal operations only + } + + private var currentMode = PowerMode.BALANCED + private var isCharging = false + private var batteryLevel = 100 + private var isAppInBackground = false + + private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var dutyCycleJob: Job? = null + + var delegate: PowerManagerDelegate? = null + + // Battery monitoring + private val batteryReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_BATTERY_CHANGED -> { + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + if (level != -1 && scale != -1) { + batteryLevel = (level * 100) / scale + } + + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + + updatePowerMode() + } + Intent.ACTION_POWER_CONNECTED -> { + isCharging = true + updatePowerMode() + } + Intent.ACTION_POWER_DISCONNECTED -> { + isCharging = false + updatePowerMode() + } + } + } + } + + init { + registerBatteryReceiver() + updatePowerMode() + } + + fun start() { + Log.i(TAG, "Starting power management") + startDutyCycle() + } + + fun stop() { + Log.i(TAG, "Stopping power management") + powerScope.cancel() + unregisterBatteryReceiver() + } + + fun setAppBackgroundState(inBackground: Boolean) { + if (isAppInBackground != inBackground) { + isAppInBackground = inBackground + Log.d(TAG, "App background state changed: $inBackground") + updatePowerMode() + } + } + + /** + * Get scan settings optimized for current power mode + */ + fun getScanSettings(): ScanSettings { + return when (currentMode) { + PowerMode.PERFORMANCE -> ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) + .setReportDelay(50) // Reduced from 10ms + .build() + + PowerMode.BALANCED -> ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_BALANCED) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) + .setReportDelay(200) + .build() + + PowerMode.POWER_SAVER -> ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setMatchMode(ScanSettings.MATCH_MODE_STICKY) + .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) + .setReportDelay(1000) + .build() + + PowerMode.ULTRA_LOW_POWER -> ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setMatchMode(ScanSettings.MATCH_MODE_STICKY) + .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) + .setReportDelay(5000) + .build() + } + } + + /** + * Get advertising settings optimized for current power mode + */ + fun getAdvertiseSettings(): AdvertiseSettings { + return when (currentMode) { + PowerMode.PERFORMANCE -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setConnectable(true) + .setTimeout(0) + .build() + + PowerMode.BALANCED -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .setConnectable(true) + .setTimeout(0) + .build() + + PowerMode.POWER_SAVER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) + .setConnectable(true) + .setTimeout(0) + .build() + + PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW) + .setConnectable(true) + .setTimeout(0) + .build() + } + } + + /** + * Get maximum allowed connections for current power mode + */ + fun getMaxConnections(): Int { + return when (currentMode) { + PowerMode.PERFORMANCE -> MAX_CONNECTIONS_NORMAL + PowerMode.BALANCED -> MAX_CONNECTIONS_NORMAL + PowerMode.POWER_SAVER -> MAX_CONNECTIONS_POWER_SAVE + PowerMode.ULTRA_LOW_POWER -> MAX_CONNECTIONS_ULTRA_LOW + } + } + + /** + * Get RSSI filter threshold for current power mode + */ + fun getRSSIThreshold(): Int { + return when (currentMode) { + PowerMode.PERFORMANCE -> -95 + PowerMode.BALANCED -> -85 + PowerMode.POWER_SAVER -> -75 + PowerMode.ULTRA_LOW_POWER -> -65 + } + } + + /** + * Check if duty cycling should be used + */ + fun shouldUseDutyCycle(): Boolean { + return currentMode != PowerMode.PERFORMANCE + } + + /** + * Get current power mode information + */ + fun getPowerInfo(): String { + return buildString { + appendLine("=== Power Manager Status ===") + appendLine("Current Mode: $currentMode") + appendLine("Battery Level: $batteryLevel%") + appendLine("Is Charging: $isCharging") + appendLine("App In Background: $isAppInBackground") + appendLine("Max Connections: ${getMaxConnections()}") + appendLine("RSSI Threshold: ${getRSSIThreshold()} dBm") + appendLine("Use Duty Cycle: ${shouldUseDutyCycle()}") + } + } + + private fun updatePowerMode() { + val newMode = when { + // Always use performance mode when charging (unless in background too long) + isCharging && !isAppInBackground -> PowerMode.PERFORMANCE + + // Critical battery - use ultra low power + batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER + + // Low battery - use power saver + batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER + + // Background app with medium battery - use power saver + isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER + + // Background app with good battery - use balanced + isAppInBackground -> PowerMode.BALANCED + + // Foreground with good battery - use balanced + else -> PowerMode.BALANCED + } + + if (newMode != currentMode) { + val oldMode = currentMode + currentMode = newMode + Log.i(TAG, "Power mode changed: $oldMode → $newMode (battery: $batteryLevel%, charging: $isCharging, background: $isAppInBackground)") + + delegate?.onPowerModeChanged(currentMode) + + // Restart duty cycle with new parameters + if (shouldUseDutyCycle()) { + startDutyCycle() + } else { + stopDutyCycle() + } + } + } + + private fun startDutyCycle() { + stopDutyCycle() + + if (!shouldUseDutyCycle()) { + delegate?.onScanStateChanged(true) // Always scan in performance mode + return + } + + val (onDuration, offDuration) = when (currentMode) { + PowerMode.BALANCED -> SCAN_ON_DURATION_NORMAL to SCAN_OFF_DURATION_NORMAL + PowerMode.POWER_SAVER -> SCAN_ON_DURATION_POWER_SAVE to SCAN_OFF_DURATION_POWER_SAVE + PowerMode.ULTRA_LOW_POWER -> SCAN_ON_DURATION_ULTRA_LOW to SCAN_OFF_DURATION_ULTRA_LOW + PowerMode.PERFORMANCE -> return // No duty cycle + } + + dutyCycleJob = powerScope.launch { + while (isActive && shouldUseDutyCycle()) { + // Scan ON period + Log.d(TAG, "Duty cycle: Scan ON for ${onDuration}ms") + delegate?.onScanStateChanged(true) + delay(onDuration) + + // Scan OFF period (keep advertising active) + if (isActive && shouldUseDutyCycle()) { + Log.d(TAG, "Duty cycle: Scan OFF for ${offDuration}ms") + delegate?.onScanStateChanged(false) + delay(offDuration) + } + } + } + + Log.i(TAG, "Started duty cycle: ${onDuration}ms ON, ${offDuration}ms OFF") + } + + private fun stopDutyCycle() { + dutyCycleJob?.cancel() + dutyCycleJob = null + } + + private fun registerBatteryReceiver() { + try { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_BATTERY_CHANGED) + addAction(Intent.ACTION_POWER_CONNECTED) + addAction(Intent.ACTION_POWER_DISCONNECTED) + } + context.registerReceiver(batteryReceiver, filter) + } catch (e: Exception) { + Log.w(TAG, "Failed to register battery receiver: ${e.message}") + } + } + + private fun unregisterBatteryReceiver() { + try { + context.unregisterReceiver(batteryReceiver) + } catch (e: Exception) { + Log.w(TAG, "Failed to unregister battery receiver: ${e.message}") + } + } +} + +/** + * Delegate interface for power management callbacks + */ +interface PowerManagerDelegate { + fun onPowerModeChanged(newMode: PowerManager.PowerMode) + fun onScanStateChanged(shouldScan: Boolean) +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index d4db8301..4cfb92c3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -259,6 +259,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B } } + fun setAppBackgroundState(inBackground: Boolean) { + // Forward to connection manager for power optimization + meshService.connectionManager.setAppBackgroundState(inBackground) + } + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) {