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 3bdaa842..faaa3a45 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -15,34 +15,58 @@ 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 + + // Connection attempt tracking with automatic cleanup + private val pendingConnections = ConcurrentHashMap() + + // Service state + private var isActive = false + private var scanCallback: ScanCallback? = null + private var advertiseCallback: AdvertiseCallback? = null + + // CRITICAL FIX: Scan rate limiting to prevent "scanning too frequently" errors + private var lastScanStartTime = 0L + private var lastScanStopTime = 0L + private var isCurrentlyScanning = false + private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts // Delegate for callbacks var delegate: BluetoothConnectionManagerDelegate? = null @@ -51,10 +75,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") @@ -72,19 +127,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 (CLIENT ONLY)") } return true @@ -96,82 +158,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}") } } } @@ -179,47 +244,85 @@ 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() - 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("Peripheral RSSI: ${peripheralRSSI.size}") - peripheralRSSI.forEach { (address, rssi) -> - appendLine(" - $address: $rssi dBm") + appendLine("Pending Connections: ${pendingConnections.size}") + 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 { + // CRITICAL FIX: Avoid rapid scan restarts by checking if we need to change scan behavior + val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() + + // Update advertising with new power settings + stopAdvertising() + delay(100) + startAdvertising() + + // Only restart scanning if the duty cycle behavior changed + val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() + if (wasUsingDutyCycle != nowUsingDutyCycle) { + Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan") + stopScanning() + delay(1000) // Extra delay to avoid rate limiting + + if (nowUsingDutyCycle) { + Log.i(TAG, "Switching to duty cycle scanning mode") + // Duty cycle will handle scanning + } else { + Log.i(TAG, "Switching to continuous scanning mode") + startScanning() + } + } else { + Log.d(TAG, "Duty cycle behavior unchanged, keeping existing scan state") + } + + // Enforce connection limits + enforceConnectionLimits() + } + } + + override fun onScanStateChanged(shouldScan: Boolean) { + if (shouldScan) { + startScanning() + } else { + stopScanning() + } + } + + // MARK: - Private Implementation + private fun hasBluetoothPermissions(): Boolean { val permissions = mutableListOf() @@ -246,9 +349,6 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } } - /** - * Setup GATT server for peripheral mode - */ @Suppress("DEPRECATION") private fun setupGattServer() { if (!hasBluetoothPermissions()) return @@ -257,14 +357,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) } } } @@ -279,8 +381,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", "") @@ -304,14 +404,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) } } @@ -321,13 +418,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 @@ -338,14 +434,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) @@ -354,49 +448,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 - } - - val settings = AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) - .setConnectable(true) - .setTimeout(0) - .build() + if (!hasBluetoothPermissions() || bleAdvertiser == null || !isActive) return + + 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") } } @@ -407,187 +477,306 @@ 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") + if (!hasBluetoothPermissions() || bleScanner == null || !isActive) return + + // CRITICAL FIX: Rate limit scan starts to prevent "scanning too frequently" errors + val currentTime = System.currentTimeMillis() + if (isCurrentlyScanning) { + Log.d(TAG, "Scan already in progress, skipping start request") return } + val timeSinceLastStart = currentTime - lastScanStartTime + if (timeSinceLastStart < scanRateLimit) { + val remainingWait = scanRateLimit - timeSinceLastStart + Log.w(TAG, "Scan rate limited: need to wait ${remainingWait}ms before starting scan") + + // Schedule delayed scan start + connectionScope.launch { + delay(remainingWait) + if (isActive && !isCurrentlyScanning) { + startScanning() + } + } + return + } + + // DIAGNOSTIC: Add both filtered and unfiltered scanning for debugging 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 scanFilters = listOf(scanFilter) - val scanCallback = object : ScanCallback() { + Log.d(TAG, "Starting BLE scan with target service UUID: $SERVICE_UUID") + + scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { + // DEBUG: Log ALL scan results first + val device = result.device + val rssi = result.rssi + val scanRecord = result.scanRecord + Log.d(TAG, "Scan result: device: ${device.address}, Name: '${device.name}', RSSI: $rssi") handleScanResult(result) } override fun onBatchScanResults(results: MutableList) { - results.forEach { handleScanResult(it) } + Log.d(TAG, "Batch scan results received: ${results.size} devices") + results.forEach { result -> + handleScanResult(result) + } } 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: $errorCode") + isCurrentlyScanning = false + lastScanStopTime = System.currentTimeMillis() + + when (errorCode) { + 1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED") + 2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED") + 3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR") + 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") + 5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES") + 6 -> { + Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY") + Log.w(TAG, "Scan failed due to rate limiting - will retry after delay") + connectionScope.launch { + delay(10000) // Wait 10 seconds before retrying + if (isActive) { + startScanning() + } + } + } + else -> Log.e(TAG, "Unknown scan failure code: $errorCode") } - Log.e(TAG, "Scan failed: $errorMessage") } } try { - bleScanner.startScan(listOf(scanFilter), scanSettings, scanCallback) - Log.i(TAG, "Started BLE scanning") + lastScanStartTime = currentTime + isCurrentlyScanning = true + + bleScanner.startScan(scanFilters, powerManager.getScanSettings(), scanCallback) + Log.d(TAG, "BLE scan started successfully") } catch (e: Exception) { Log.e(TAG, "Exception starting scan: ${e.message}") + isCurrentlyScanning = false } } - /** - * Stop BLE scanning - */ @Suppress("DEPRECATION") private fun stopScanning() { if (!hasBluetoothPermissions() || bleScanner == null) return - bleScanner.stopScan(object : ScanCallback() {}) + + if (isCurrentlyScanning) { + try { + scanCallback?.let { + bleScanner.stopScan(it) + Log.d(TAG, "BLE scan stopped successfully") + } + } catch (e: Exception) { + Log.w(TAG, "Error stopping scan: ${e.message}") + } + + isCurrentlyScanning = false + lastScanStopTime = System.currentTimeMillis() + } } - /** - * Handle scan result and connect to discovered devices - */ - @Suppress("DEPRECATION") private fun handleScanResult(result: ScanResult) { val device = result.device val rssi = result.rssi + val deviceAddress = device.address + val scanRecord = result.scanRecord - // Filter out weak signals - if (rssi < -90) { + // CRITICAL: Only process devices that have our service UUID + val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } == true + if (!hasOurService) { return } - // Check if already connected - if (connectedDevices.values.any { it.address == device.address }) { + // FIXED: Extract peer ID from device name for iOS compatibility + val deviceName = device.name + val extractedPeerID = if (deviceName != null && deviceName.length == 8) { + deviceName + } else { + null + } + + Log.d(TAG, "Processing bitchat device: $deviceAddress, name: '$deviceName', peerID: $extractedPeerID, RSSI: $rssi") + + // Power-aware RSSI filtering + if (rssi < powerManager.getRSSIThreshold()) { + Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}") return } - // Store RSSI - peripheralRSSI[device.address] = rssi + // CRITICAL FIX: Prevent multiple simultaneous connections to same device + // Check if already connected OR already attempting to connect + if (connectedDevices.containsKey(deviceAddress)) { + Log.d(TAG, "Device $deviceAddress already connected, skipping") + return + } - Log.i(TAG, "Found bitchat service at ${device.address} (RSSI: $rssi), connecting...") + // CRITICAL FIX: Check if connection attempt is already in progress + val existingAttempt = pendingConnections[deviceAddress] + if (existingAttempt != null && !existingAttempt.isExpired()) { + if (!existingAttempt.shouldRetry()) { + Log.d(TAG, "Connection to $deviceAddress already in progress or too many recent attempts (${existingAttempt.attempts})") + return + } + } - // Connect to device - connectToDevice(device) + if (connectedDevices.size >= powerManager.getMaxConnections()) { + Log.d(TAG, "Connection limit reached (${powerManager.getMaxConnections()})") + return + } + + // CRITICAL FIX: Use synchronized block to prevent race conditions + synchronized(pendingConnections) { + // Double-check inside synchronized block + val currentAttempt = pendingConnections[deviceAddress] + if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { + Log.d(TAG, "Connection to $deviceAddress blocked by concurrent attempt check") + return + } + + // Update connection attempt atomically + val attempts = (currentAttempt?.attempts ?: 0) + 1 + pendingConnections[deviceAddress] = ConnectionAttempt(attempts) + + if (extractedPeerID != null) { + Log.i(TAG, "Initiating connection to peer $extractedPeerID at $deviceAddress (RSSI: $rssi, attempt: $attempts)") + } else { + Log.i(TAG, "Initiating connection to device with bitchat service at $deviceAddress (RSSI: $rssi, attempt: $attempts)") + } + + // Start connection immediately while holding lock + connectToDevice(device, rssi) + } } - /** - * Connect to a discovered device - */ @Suppress("DEPRECATION") - private fun connectToDevice(device: BluetoothDevice) { + private fun connectToDevice(device: BluetoothDevice, rssi: Int) { if (!hasBluetoothPermissions()) return - val gattCallback = object : BluetoothGattCallback() { + val deviceAddress = device.address + + val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - when (newState) { - BluetoothProfile.STATE_CONNECTED -> { - Log.d(TAG, "Connected to ${gatt.device.address} as client") - connectedDevices[gatt.device.address] = gatt.device - gattConnections[gatt.device] = gatt - gatt.discoverServices() + Log.d(TAG, "Client: Connection state change - Device: $deviceAddress, Status: $status, NewState: $newState") + + if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "Client: Successfully connected to $deviceAddress. Requesting MTU...") + // FIX: Request a larger MTU. Must be done before any data transfer. + // 517 is the maximum supported MTU size on Android. + connectionScope.launch { + delay(200) // A small delay can improve reliability of MTU request. + gatt.requestMtu(517) } - BluetoothProfile.STATE_DISCONNECTED -> { - Log.d(TAG, "Disconnected from ${gatt.device.address}") - connectedDevices.remove(gatt.device.address) - deviceCharacteristics.remove(gatt.device) - gattConnections.remove(gatt.device) - gatt.close() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "Client: Disconnected from $deviceAddress with error status $status") + if (status == 147) { + Log.e(TAG, "Client: Connection establishment failed (status 147) for $deviceAddress") + } + } else { + Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress") + } + + cleanupDeviceConnection(deviceAddress) + + connectionScope.launch { + delay(CLEANUP_DELAY) + try { + gatt.close() + } catch (e: Exception) { + Log.w(TAG, "Error closing GATT: ${e.message}") + } } } } + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { + val deviceAddress = gatt.device.address + Log.i(TAG, "Client: MTU changed for $deviceAddress to $mtu with status $status") + + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "MTU successfully negotiated for $deviceAddress. Discovering services.") + + // Now that MTU is set, connection is fully ready. + val deviceConn = DeviceConnection( + device = gatt.device, + gatt = gatt, + rssi = rssi, + isClient = true + ) + connectedDevices[deviceAddress] = deviceConn + pendingConnections.remove(deviceAddress) + + // Start service discovery only AFTER MTU is set. + gatt.discoverServices() + } else { + Log.w(TAG, "MTU negotiation failed for $deviceAddress with status: $status. Disconnecting.") + pendingConnections.remove(deviceAddress) + gatt.disconnect() + } + } + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + Log.d(TAG, "Client: Service discovery completed for $deviceAddress with status: $status") + if (status == BluetoothGatt.GATT_SUCCESS) { val service = gatt.getService(SERVICE_UUID) - val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID) - - if (characteristic != null) { - deviceCharacteristics[gatt.device] = characteristic - gatt.setCharacteristicNotification(characteristic, true) - - // Enable notifications - val descriptor = characteristic.getDescriptor( - UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - ) - descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - gatt.writeDescriptor(descriptor) - - // Notify delegate - connectionScope.launch { - delay(200) - delegate?.onDeviceConnected(gatt.device) + if (service != null) { + val characteristic = service.getCharacteristic(CHARACTERISTIC_UUID) + if (characteristic != null) { + connectedDevices[deviceAddress]?.let { deviceConn -> + val updatedConn = deviceConn.copy(characteristic = characteristic) + connectedDevices[deviceAddress] = updatedConn + Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress") + } + + gatt.setCharacteristicNotification(characteristic, true) + val descriptor = characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) + if (descriptor != null) { + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(descriptor) + + connectionScope.launch { + delay(200) + Log.i(TAG, "Client: Connection setup complete for $deviceAddress") + delegate?.onDeviceConnected(device) + } + } else { + Log.e(TAG, "Client: CCCD descriptor not found for $deviceAddress") + gatt.disconnect() + } + } else { + Log.e(TAG, "Client: Required characteristic not found for $deviceAddress") + gatt.disconnect() } + } else { + Log.e(TAG, "Client: Required service not found for $deviceAddress") + gatt.disconnect() } + } else { + Log.e(TAG, "Client: Service discovery failed with status $status for $deviceAddress") + gatt.disconnect() } } @@ -601,18 +790,98 @@ class BluetoothConnectionManager(private val context: Context, private val myPee } } - device.connectGatt(context, false, gattCallback) + try { + Log.d(TAG, "Attempting GATT connection to $deviceAddress with autoConnect=false") + val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + if (gatt == null) { + Log.e(TAG, "connectGatt returned null for $deviceAddress") + pendingConnections.remove(deviceAddress) + } else { + Log.d(TAG, "GATT connection initiated successfully for $deviceAddress") + } + } catch (e: Exception) { + Log.e(TAG, "Exception connecting to $deviceAddress: ${e.message}") + pendingConnections.remove(deviceAddress) + } + } + + 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 } + } + // CRITICAL FIX: Always remove from pending connections when cleaning up + // This prevents failed connections from blocking future attempts + pendingConnections.remove(deviceAddress) + Log.d(TAG, "Cleaned up device connection for $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() + pendingConnections.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 065f775e..0a66bcf8 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) @@ -55,6 +55,26 @@ class BluetoothMeshService(private val context: Context) { init { setupDelegates() + startPeriodicDebugLogging() + } + + /** + * Start periodic debug logging every 10 seconds + */ + private fun startPeriodicDebugLogging() { + serviceScope.launch { + while (isActive) { + try { + delay(10000) // 10 seconds + val debugInfo = getDebugStatus() + Log.d(TAG, "=== PERIODIC DEBUG STATUS ===") + Log.d(TAG, debugInfo) + Log.d(TAG, "=== END DEBUG STATUS ===") + } catch (e: Exception) { + Log.e(TAG, "Error in periodic debug logging: ${e.message}") + } + } + } } /** @@ -330,10 +350,7 @@ class BluetoothMeshService(private val context: Context) { ) // Send with random delay and retry for reliability - delay(Random.nextLong(50, 500)) - connectionManager.broadcastPacket(packet) - - delay(300 + Random.nextLong(0, 200)) + // delay(Random.nextLong(50, 500)) connectionManager.broadcastPacket(packet) } } 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..2c8bcab0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -0,0 +1,334 @@ +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 { + // CRITICAL FIX: Set reportDelay to 0 for all modes. + // When using a custom duty cycle, we want scan results delivered immediately, + // not batched. A non-zero report delay can conflict with the scan window, + // causing missed results if the scan stops before the delay is met. + val builder = ScanSettings.Builder() + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + + when (currentMode) { + PowerMode.PERFORMANCE -> builder + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) + + PowerMode.BALANCED -> builder + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) + + PowerMode.POWER_SAVER -> builder + .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) + .setMatchMode(ScanSettings.MATCH_MODE_STICKY) + .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) + + PowerMode.ULTRA_LOW_POWER -> builder + .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) + .setMatchMode(ScanSettings.MATCH_MODE_STICKY) + .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) + } + + return builder.setReportDelay(0).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) {