diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 282f3295..ecd32327 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -13,6 +13,9 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() + // Start the single process-wide power policy before transport components are constructed. + com.bitchat.android.mesh.PowerManager.getInstance(this).start() + // Initialize Tor first so any early network goes over Tor try { val torProvider = ArtiTorManager.getInstance() @@ -53,6 +56,10 @@ class BitchatApplication : Application() { com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this) } catch (_: Exception) { } + // Own relay connectivity, selected-channel subscriptions, and presence scheduling at the + // process level so closing the Activity does not disconnect Nostr. + try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { } + // Initialize mesh service preferences try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } 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 17e4ce28..35a3d9f4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -18,7 +18,7 @@ class BluetoothConnectionManager( private val context: Context, private val myPeerID: String, private val fragmentManager: FragmentManager? = null -) : PowerManagerDelegate { +) { companion object { private const val TAG = "BluetoothConnectionManager" @@ -30,7 +30,7 @@ class BluetoothConnectionManager( private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter // Power management - private val powerManager = PowerManager(context.applicationContext) + private val powerManager = PowerManager.getInstance(context.applicationContext) // Coroutines private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -117,7 +117,19 @@ class BluetoothConnectionManager( } init { - powerManager.delegate = this + connectionScope.launch { + var previousMode: PowerManager.PowerMode? = null + powerManager.profile.collect { profile -> + val modeChanged = previousMode != null && previousMode != profile.mode + previousMode = profile.mode + if (!isActive || !isBleTransportEnabled()) return@collect + + if (modeChanged && isGattServerEnabled()) { + serverManager.restartAdvertising() + } + clientManager.applyPowerProfile(profile) + } + } // Observe debug settings to enforce role state while active try { val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() @@ -294,9 +306,6 @@ class BluetoothConnectionManager( clientManager.stop() serverManager.stop() - // Stop power manager - powerManager.stop() - // Stop connection tracker connectionTracker.stop() @@ -451,54 +460,6 @@ class BluetoothConnectionManager( } } - // MARK: - PowerManagerDelegate Implementation - - override fun onPowerModeChanged(newMode: PowerManager.PowerMode) { - Log.i(TAG, "Power mode changed to: $newMode") - - connectionScope.launch { - if (!isActive || !isBleTransportEnabled()) { - serverManager.stop() - clientManager.stop() - return@launch - } - - // Avoid rapid scan restarts by checking if we need to change scan behavior - val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() - - // Update advertising with new power settings if server enabled - val serverEnabled = isGattServerEnabled() - if (serverEnabled) { - serverManager.restartAdvertising() - } else { - serverManager.stop() - } - - // Only restart scanning if the duty cycle behavior changed - val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() - if (wasUsingDutyCycle != nowUsingDutyCycle) { - val clientEnabled = isGattClientEnabled() - if (clientEnabled) { - clientManager.restartScanning() - } else { - clientManager.stop() - } - } - - // Enforce connection limits - enforceStrictLimits() - } - } - - override fun onScanStateChanged(shouldScan: Boolean) { - if (!isActive || !isBleTransportEnabled()) { - clientManager.onScanStateChanged(false) - return - } - clientManager.onScanStateChanged(shouldScan) - } - - // MARK: - Private Implementation - All moved to component managers } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 66971ea4..58bb1f48 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -93,6 +93,7 @@ class BluetoothGattClientManager( @Volatile private var lastScanResultTime = 0L private var scanRetryCount = 0 private var scanWatchdogJob: Job? = null + private var scanDutyCycleJob: Job? = null // RSSI monitoring state private var rssiMonitoringJob: Job? = null @@ -131,18 +132,9 @@ class BluetoothGattClientManager( isActive = true connectionScope.launch { - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Using power-aware duty cycling") - // Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that. - } else { - scanningDesired = true - startScanning() - } - + applyPowerProfile(powerManager.profile.value) // Start RSSI monitoring startRSSIMonitoring() - // Start the scan watchdog so a silently-dead or wedged scanner self-heals. - startScanWatchdog() } return true @@ -153,6 +145,8 @@ class BluetoothGattClientManager( */ fun stop() { scanningDesired = false + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null stopScanWatchdog() if (!isActive) { // Idempotent stop @@ -208,10 +202,10 @@ class BluetoothGattClientManager( Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") } } - delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS) + delay(powerManager.profile.value.ble.rssiPollIntervalMs) } catch (e: Exception) { Log.w(TAG, "Error in RSSI monitoring: ${e.message}") - delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS) + delay(powerManager.profile.value.ble.rssiPollIntervalMs) } } } @@ -659,13 +653,37 @@ class BluetoothGattClientManager( connectionScope.launch { stopScanning() delay(1000) // Extra delay to avoid rate limiting - - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Switching to duty cycle scanning mode") - // Duty cycle will handle scanning - } else { - Log.i(TAG, "Switching to continuous scanning mode") - startScanning() + applyPowerProfile(powerManager.profile.value) + } + } + + /** + * Apply the current process-wide profile without ever disabling background discovery. + */ + fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) { + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null + if (!isActive || !isClientRoleEnabled()) { + onScanStateChanged(false) + return + } + + if (profile.ble.continuousScan) { + startScanWatchdog() + onScanStateChanged(true) + return + } + + // Duty-cycled scans are re-armed every window, so the continuous-scan watchdog would only + // create background wakeups during intentional OFF periods. + stopScanWatchdog() + scanDutyCycleJob = connectionScope.launch { + while (isActive && isClientRoleEnabled()) { + onScanStateChanged(true) + delay(profile.ble.scanOnMs) + if (!isActive || !isClientRoleEnabled()) break + onScanStateChanged(false) + delay(profile.ble.scanOffMs) } } } 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 55cdb6df..bdd49d8e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -126,7 +126,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic var delegate: BluetoothMeshDelegate? = null // Coroutines - private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false private val provisionalBleClaims = @@ -217,23 +216,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } - /** - * Send broadcast announcement every 30 seconds - */ - private fun sendPeriodicBroadcastAnnounce() { - announceJob?.cancel() - announceJob = serviceScope.launch { - while (isActive) { - try { - delay(30000) // 30 seconds - sendBroadcastAnnounce() - } catch (e: Exception) { - Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") - } - } - } - } - /** * Setup delegate connections between components */ @@ -778,8 +760,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic isActive = true TransportBridgeService.register("BLE", this) - // Start periodic announcements for peer discovery and connectivity - sendPeriodicBroadcastAnnounce() // Start periodic syncs com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE") } else { @@ -801,8 +781,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private fun pauseServicesForTransportDisable() { Log.i(TAG, "Disabling BLE mesh transport") isActive = false - announceJob?.cancel() - announceJob = null com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") TransportBridgeService.unregister("BLE") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } @@ -822,8 +800,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false - announceJob?.cancel() - announceJob = null TransportBridgeService.unregister("BLE") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 375e6531..0c963cc1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -116,7 +116,6 @@ class MeshCore( var delegate: MeshDelegate? = null - private var announceJob: Job? = null private var isActive = false init { @@ -145,7 +144,6 @@ class MeshCore( fun startCore() { if (isActive) return isActive = true - startPeriodicBroadcastAnnounce() if (ownsGossipManager) { gossipSyncManager.start() } @@ -154,8 +152,6 @@ class MeshCore( fun stopCore() { if (!isActive) return isActive = false - announceJob?.cancel() - announceJob = null if (ownsGossipManager) { gossipSyncManager.stop() } @@ -195,18 +191,6 @@ class MeshCore( TransportBridgeService.broadcast(transport.id, routed) } - private fun startPeriodicBroadcastAnnounce() { - announceJob?.cancel() - announceJob = scope.launch { - while (isActive) { - try { - delay(30_000) - sendBroadcastAnnounce() - } catch (_: Exception) { } - } - } - } - private fun setupDelegates() { peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 3096828a..9714c63a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -14,327 +14,261 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import kotlinx.coroutines.* -import kotlin.math.max +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** - * Power-aware Bluetooth management for bitchat - * Adjusts scanning, advertising, and connection behavior based on battery state + * Process-wide source of truth for background and battery-aware scheduling. + * + * The manager deliberately exposes policy only. Consumers retain ownership of their hardware and + * network jobs, so changing profile never implies disconnecting a transport or suspending Tor. */ -class PowerManager(private val context: Context) : LifecycleEventObserver { - +class PowerManager private constructor(context: Context) : LifecycleEventObserver { + + enum class PowerMode { + PERFORMANCE, + BALANCED, + POWER_SAVER, + ULTRA_LOW_POWER + } + + enum class BatteryBand { NORMAL, LOW, CRITICAL } + + data class BleSchedule( + val scanOnMs: Long, + val scanOffMs: Long, + val continuousScan: Boolean, + val rssiPollIntervalMs: Long, + val maxConnections: Int = 8 + ) + + data class WifiAwareSchedule( + val tcpKeepAliveMs: Long, + val discoveryKeepAliveMs: Long, + val connectionMaintenanceMs: Long, + val discoveryIdleRefreshMs: Long, + val discoverySessionRefreshMinMs: Long, + val discoveryStaleMs: Long + ) + + data class NostrSchedule( + val presenceHeartbeatMinMs: Long, + val presenceHeartbeatMaxMs: Long, + val subscriptionValidationMs: Long + ) + + data class RuntimePerformanceProfile( + val mode: PowerMode, + val batteryBand: BatteryBand, + val isBackground: Boolean, + val isCharging: Boolean, + val hasDirectPeers: Boolean, + val ble: BleSchedule, + val meshAnnouncementIntervalMs: Long, + val wifiAware: WifiAwareSchedule, + val nostr: NostrSchedule + ) + companion object { private const val TAG = "PowerManager" - - // Battery thresholds - private const val CRITICAL_BATTERY = com.bitchat.android.util.AppConstants.Power.CRITICAL_BATTERY_PERCENT - private const val LOW_BATTERY = com.bitchat.android.util.AppConstants.Power.LOW_BATTERY_PERCENT - private const val MEDIUM_BATTERY = com.bitchat.android.util.AppConstants.Power.MEDIUM_BATTERY_PERCENT - - // Scan duty cycle periods (ms) - private const val SCAN_ON_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_NORMAL_MS // 8 seconds on - private const val SCAN_OFF_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_NORMAL_MS // 2 seconds off - private const val SCAN_ON_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_POWER_SAVE_MS // 2 seconds on - private const val SCAN_OFF_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_POWER_SAVE_MS // 8 seconds off - private const val SCAN_ON_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_ULTRA_LOW_MS // 1 second on - private const val SCAN_OFF_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_ULTRA_LOW_MS // 10 seconds off - - // Connection limits - private const val MAX_CONNECTIONS_NORMAL = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_NORMAL - private const val MAX_CONNECTIONS_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_POWER_SAVE - private const val MAX_CONNECTIONS_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_ULTRA_LOW + + @Volatile + private var INSTANCE: PowerManager? = null + + fun getInstance(context: Context): PowerManager = + INSTANCE ?: synchronized(this) { + INSTANCE ?: PowerManager(context.applicationContext).also { INSTANCE = it } + } } - - 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 = true - - private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var dutyCycleJob: Job? = null - - var delegate: PowerManagerDelegate? = null - - // Battery monitoring + + private val appContext = context.applicationContext + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @Volatile private var batteryLevel = 100 + @Volatile private var isCharging = false + @Volatile private var isAppInBackground = true + @Volatile private var hasDirectPeers = false + @Volatile private var shutdown = false + + private val _profile = MutableStateFlow( + PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + ) + val profile: StateFlow = _profile.asStateFlow() + 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 - } - + if (level >= 0 && scale > 0) batteryLevel = (level * 100) / scale + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL - - updatePowerMode() + status == BatteryManager.BATTERY_STATUS_FULL + refreshProfile() } Intent.ACTION_POWER_CONNECTED -> { isCharging = true - updatePowerMode() + refreshProfile() } Intent.ACTION_POWER_DISCONNECTED -> { isCharging = false - updatePowerMode() + refreshProfile() } } } } - + init { registerBatteryReceiver() - - // Register for process lifecycle events on the main thread Handler(Looper.getMainLooper()).post { try { ProcessLifecycleOwner.get().lifecycle.addObserver(this) + isAppInBackground = !ProcessLifecycleOwner.get().lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + refreshProfile() } catch (e: Exception) { - Log.e(TAG, "Failed to register lifecycle observer: ${e.message}") + Log.e(TAG, "Failed to register process lifecycle observer: ${e.message}") } } - - updatePowerMode() + scope.launch { + AppStateStore.directPeers + .collect { peers -> + hasDirectPeers = peers.isNotEmpty() + refreshProfile() + } + } } - - fun start() { - Log.i(TAG, "Starting power management") - startDutyCycle() - } - - fun stop() { - Log.i(TAG, "Stopping power management") - powerScope.cancel() - unregisterBatteryReceiver() - - // Unregister lifecycle observer + + /** Kept for existing callers; initialization is eager and idempotent. */ + fun start() = Unit + + /** + * Process-level teardown for explicit full application shutdown only. + * Transport stop/restart paths must not call this. + */ + fun shutdown() { + if (shutdown) return + shutdown = true + scope.cancel() + try { appContext.unregisterReceiver(batteryReceiver) } catch (_: Exception) { } Handler(Looper.getMainLooper()).post { - try { - ProcessLifecycleOwner.get().lifecycle.removeObserver(this) - } catch (e: Exception) { - Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}") - } + try { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } catch (_: Exception) { } } } - + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_START -> { - Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)") isAppInBackground = false - updatePowerMode() + refreshProfile() } Lifecycle.Event.ON_STOP -> { - Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)") isAppInBackground = true - updatePowerMode() + refreshProfile() } - else -> {} + else -> Unit } } - - /** - * Get scan settings optimized for current power mode - */ + fun getScanSettings(): ScanSettings { + val current = profile.value val builder = ScanSettings.Builder() .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - when (currentMode) { + when (current.mode) { 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_BALANCED) .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.POWER_SAVER, PowerMode.ULTRA_LOW_POWER -> builder .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .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() - } + + fun getAdvertiseSettings(): AdvertiseSettings = when (profile.value.mode) { + 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 - } + + fun getMaxConnections(): Int = profile.value.ble.maxConnections + + fun getRSSIThreshold(): Int = when (profile.value.mode) { + PowerMode.PERFORMANCE -> -95 + PowerMode.BALANCED -> -85 + PowerMode.POWER_SAVER -> -75 + PowerMode.ULTRA_LOW_POWER -> -65 } - - /** - * 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 { + + fun shouldUseDutyCycle(): Boolean = !profile.value.ble.continuousScan + + fun getPowerInfo(): String = profile.value.let { current -> + 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()}") + appendLine("Current Mode: ${current.mode}") + appendLine("Battery Band: ${current.batteryBand} ($batteryLevel%)") + appendLine("Is Charging: ${current.isCharging}") + appendLine("App In Background: ${current.isBackground}") + appendLine("Has Direct Peers: ${current.hasDirectPeers}") + appendLine("BLE Scan: ${current.ble.scanOnMs}ms ON / ${current.ble.scanOffMs}ms OFF") + appendLine("Max Connections: ${current.ble.maxConnections}") } } - - private fun updatePowerMode() { - // Determine the base mode from battery/charging state only - val baseMode = when { - // Charging in foreground may use performance - isCharging && !isAppInBackground -> PowerMode.PERFORMANCE - // Critical battery - force ultra low power regardless of foreground/background - batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER - - // Low battery - prefer power saver - batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER - - // Otherwise balanced - else -> PowerMode.BALANCED - } - - // If app is in background (including when running as a foreground service), - // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER. - val newMode = if (isAppInBackground) { - if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER - } else { - baseMode - } - - 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 refreshProfile() { + if (shutdown) return + val next = PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + if (_profile.value != next) { + Log.i( + TAG, + "Profile changed: mode=${next.mode}, battery=${next.batteryBand}, " + + "background=${next.isBackground}, directPeers=${next.hasDirectPeers}" + ) + _profile.value = next } } - - 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 { @@ -342,25 +276,141 @@ class PowerManager(private val context: Context) : LifecycleEventObserver { addAction(Intent.ACTION_POWER_CONNECTED) addAction(Intent.ACTION_POWER_DISCONNECTED) } - context.registerReceiver(batteryReceiver, filter) + appContext.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 + * Pure policy resolver so cadence and battery-boundary behavior can be tested without Android. */ -interface PowerManagerDelegate { - fun onPowerModeChanged(newMode: PowerManager.PowerMode) - fun onScanStateChanged(shouldScan: Boolean) +internal object PowerProfileResolver { + fun resolve( + batteryLevel: Int, + isCharging: Boolean, + isBackground: Boolean, + hasDirectPeers: Boolean + ): PowerManager.RuntimePerformanceProfile { + val batteryBand = when { + batteryLevel <= AppConstants.Power.CRITICAL_BATTERY_PERCENT -> + PowerManager.BatteryBand.CRITICAL + batteryLevel <= AppConstants.Power.LOW_BATTERY_PERCENT -> + PowerManager.BatteryBand.LOW + else -> PowerManager.BatteryBand.NORMAL + } + + val mode = when { + isBackground && batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + isBackground -> PowerManager.PowerMode.POWER_SAVER + isCharging -> PowerManager.PowerMode.PERFORMANCE + batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.PowerMode.POWER_SAVER + else -> PowerManager.PowerMode.BALANCED + } + + val ble = when { + isBackground && hasDirectPeers -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 29_000L, + continuousScan = false, + rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + ) + isBackground -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 59_000L, + continuousScan = false, + rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + ) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L) + else -> + PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L) + } + + val announcementInterval = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> 60_000L + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> 120_000L + isBackground -> 300_000L + mode == PowerManager.PowerMode.POWER_SAVER -> 60_000L + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> 120_000L + else -> 30_000L + } + + val wifi = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + wifi(10, 60, 60, 5, 5, 10) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + wifi(20, 120, 120, 10, 10, 15) + isBackground -> + wifi(30, 180, 180, 15, 15, 20) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.WifiAwareSchedule(2_000L, 20_000L, 15_000L, 120_000L, 90_000L, 300_000L) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.WifiAwareSchedule(5_000L, 30_000L, 30_000L, 180_000L, 120_000L, 300_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.WifiAwareSchedule(10_000L, 60_000L, 60_000L, 300_000L, 180_000L, 600_000L) + else -> + PowerManager.WifiAwareSchedule(15_000L, 90_000L, 90_000L, 600_000L, 300_000L, 900_000L) + } + + val nostr = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + PowerManager.NostrSchedule(300_000L, 330_000L, 300_000L) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.NostrSchedule(600_000L, 660_000L, 600_000L) + isBackground -> + PowerManager.NostrSchedule(900_000L, 990_000L, 900_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.NostrSchedule(120_000L, 132_000L, 60_000L) + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> + PowerManager.NostrSchedule(300_000L, 330_000L, 120_000L) + else -> + PowerManager.NostrSchedule(40_000L, 80_000L, 30_000L) + } + + return PowerManager.RuntimePerformanceProfile( + mode = mode, + batteryBand = batteryBand, + isBackground = isBackground, + isCharging = isCharging, + hasDirectPeers = hasDirectPeers, + ble = ble, + meshAnnouncementIntervalMs = announcementInterval, + wifiAware = wifi, + nostr = nostr + ) + } + + private fun wifi( + tcpSeconds: Long, + discoverySeconds: Long, + maintenanceSeconds: Long, + idleMinutes: Long, + minRefreshMinutes: Long, + staleMinutes: Long + ) = PowerManager.WifiAwareSchedule( + tcpKeepAliveMs = tcpSeconds * 1_000L, + discoveryKeepAliveMs = discoverySeconds * 1_000L, + connectionMaintenanceMs = maintenanceSeconds * 1_000L, + discoveryIdleRefreshMs = idleMinutes * 60_000L, + discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L, + discoveryStaleMs = staleMinutes * 60_000L + ) + + private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) { + PowerManager.BatteryBand.NORMAL -> 60_000L + PowerManager.BatteryBand.LOW -> 120_000L + PowerManager.BatteryBand.CRITICAL -> 300_000L + } } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index ca49def9..b9f84547 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -6,7 +6,17 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.services.AppStateStore import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** * Feature-facing mesh service that hides local transport selection from the rest of the app. @@ -24,6 +34,10 @@ class UnifiedMeshService( private const val TAG = "UnifiedMeshService" } + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val powerManager = PowerManager.getInstance(context.applicationContext) + private var announcementJob: Job? = null + override val myPeerID: String get() = bluetooth.myPeerID @@ -49,14 +63,34 @@ class UnifiedMeshService( try { WifiAwareController.startIfPossible() } catch (e: Exception) { Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}") } + startAnnouncementScheduler() refreshDelegates() } override fun stopServices() { + announcementJob?.cancel() + announcementJob = null try { bluetooth.stopServices() } catch (_: Exception) { } try { WifiAwareController.stop() } catch (_: Exception) { } } + private fun startAnnouncementScheduler() { + if (announcementJob?.isActive == true) return + announcementJob = serviceScope.launch { + combine(powerManager.profile, AppStateStore.directPeers) { profile, peers -> + profile.meshAnnouncementIntervalMs to peers.isNotEmpty() + }.collectLatest { (intervalMs, hasRecipients) -> + if (!hasRecipients) return@collectLatest + // Connection-specific paths already send an immediate announce. Begin the + // periodic cadence after the configured interval to avoid a transition burst. + while (isActive) { + delay(intervalMs) + if (AppStateStore.directPeers.value.isNotEmpty()) sendBroadcastAnnounce() + } + } + } + } + override fun sendMessage(content: String, mentions: List, channel: String?) { when { isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel) diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index d560c648..b1eed65e 100644 --- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -133,6 +133,9 @@ class ArtiTorManager private constructor() { lastLogTime.set(System.currentTimeMillis()) _statusFlow.update { it.copy(lastLogLine = s) } handleArtiLogLine(s) + if (_statusFlow.value.bootstrapPercent < 100) { + armBootstrapInactivityWatchdog() + } } artiProxy = ArtiProxy.Builder(application) @@ -374,29 +377,30 @@ class ArtiTorManager private constructor() { } private fun startInactivityMonitoring() { - inactivityJob?.cancel() - inactivityJob = appScope.launch { - while (true) { - delay(INACTIVITY_TIMEOUT_MS) - val currentTime = System.currentTimeMillis() - val lastActivity = lastLogTime.get() - val timeSinceLastActivity = currentTime - lastActivity + armBootstrapInactivityWatchdog() + } - if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { - val currentMode = _statusFlow.value.mode - if (currentMode == TorMode.ON) { - val bootstrapPercent = _statusFlow.value.bootstrapPercent - if (bootstrapPercent < 100) { - Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") - currentApplication?.let { app -> - appScope.launch { - restartArti(app) - } - } - break - } - } - } + /** + * One-shot startup watchdog. Arti log activity re-arms it and successful bootstrap cancels it, + * so a healthy background Tor process has no permanent five-second polling coroutine. + */ + private fun armBootstrapInactivityWatchdog() { + inactivityJob?.cancel() + if (lifecycleState != LifecycleState.RUNNING || _statusFlow.value.bootstrapPercent >= 100) { + inactivityJob = null + return + } + inactivityJob = appScope.launch { + delay(INACTIVITY_TIMEOUT_MS) + val timeSinceLastActivity = System.currentTimeMillis() - lastLogTime.get() + if ( + timeSinceLastActivity >= INACTIVITY_TIMEOUT_MS && + _statusFlow.value.mode == TorMode.ON && + _statusFlow.value.bootstrapPercent < 100 && + lifecycleState == LifecycleState.RUNNING + ) { + Log.w(TAG, "Bootstrap inactivity detected (${timeSinceLastActivity}ms), restarting Arti") + currentApplication?.let { restartArti(it) } } } } @@ -494,6 +498,7 @@ class ArtiTorManager private constructor() { running = true ) } + stopInactivityMonitoring() completeWaitersIf(TorState.RUNNING) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt new file mode 100644 index 00000000..8abb4d6a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -0,0 +1,213 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.mesh.PowerManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.security.SecureRandom +import kotlin.random.asKotlinRandom + +/** + * Process-owned Nostr connectivity and low-volume background subscriptions. + * + * Stable subscription handlers delegate to the most recently attached UI-independent handlers. + * This avoids relay subscriptions retaining a cleared ViewModel while keeping DMs and the selected + * geohash channel alive for the lifetime of the foreground-service process. + */ +object NostrBackgroundRuntime { + private const val TAG = "NostrBackground" + private const val MAX_PENDING_EVENTS = 256 + + data class Handlers( + val accountDm: (NostrEvent, NostrIdentity) -> Unit, + val geohashMessage: (NostrEvent, String) -> Unit, + val geohashDm: (NostrEvent, String, NostrIdentity) -> Unit + ) + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + internal val eventScope: CoroutineScope + get() = scope + private val random = SecureRandom().asKotlinRandom() + private val lock = Any() + private val pendingEvents = ArrayDeque<(Handlers) -> Unit>() + + @Volatile private var handlers: Handlers? = null + @Volatile private var initialized = false + @Volatile private var activeGeohash: String? = null + @Volatile private var conversationGeohash: String? = null + private lateinit var application: Application + private lateinit var subscriptions: NostrSubscriptionManager + private lateinit var locationChannels: LocationChannelManager + + fun initialize(app: Application) { + synchronized(lock) { + if (initialized) return + initialized = true + application = app + locationChannels = LocationChannelManager.getInstance(app) + subscriptions = NostrSubscriptionManager( + app, + scope, + owner = NostrRelayManager.OWNER_BACKGROUND + ) + } + + subscriptions.connect() + subscribeAccountDm() + observeSelectedChannel() + startPresenceScheduler() + } + + fun attachHandlers(newHandlers: Handlers) { + handlers = newHandlers + val pending = synchronized(lock) { + pendingEvents.toList().also { pendingEvents.clear() } + } + pending.forEach { event -> runCatching { event(newHandlers) } } + } + + fun resetSubscriptions() { + if (!initialized) return + subscriptions.unsubscribeAllOwned() + scope.launch { + // Let CLOSE frames be queued before replacing the deterministic IDs. + delay(100) + subscribeAccountDm() + activeGeohash?.let(::subscribeSelectedGeohash) + } + } + + fun ensureConversationDm(geohash: String) { + if (!initialized || geohash == activeGeohash || geohash == conversationGeohash) return + conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") } + conversationGeohash = geohash + subscribeGeohashDm(geohash, "geo-dm-conversation-$geohash") + } + + private fun subscribeAccountDm() { + val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = "chat-messages", + handler = { event -> + dispatch { it.accountDm(event, identity) } + } + ) + } + + private fun observeSelectedChannel() { + scope.launch { + locationChannels.selectedChannel.collectLatest { channel -> + val next = (channel as? ChannelID.Location)?.channel?.geohash + val previous = activeGeohash + if (previous == next) return@collectLatest + + previous?.let { + subscriptions.unsubscribe("geohash-$it") + subscriptions.unsubscribe("geo-dm-$it") + } + activeGeohash = next + if (conversationGeohash == next) { + subscriptions.unsubscribe("geo-dm-conversation-$next") + conversationGeohash = null + } + next?.let(::subscribeSelectedGeohash) + } + } + } + + private fun subscribeSelectedGeohash(geohash: String) { + subscriptions.subscribeGeohashMessages( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3_600_000L, + limit = 200, + id = "geohash-$geohash", + handler = { event -> dispatch { it.geohashMessage(event, geohash) } } + ) + subscribeGeohashDm(geohash, "geo-dm-$geohash") + } + + private fun subscribeGeohashDm(geohash: String, subscriptionId: String) { + scope.launch { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = subscriptionId, + handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } } + ) + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + } + + private fun startPresenceScheduler() { + val powerManager = PowerManager.getInstance(application) + scope.launch { + val nostrSchedule = powerManager.profile + .map { it.nostr } + .distinctUntilChanged() + combine(locationChannels.availableChannels, nostrSchedule) { channels, schedule -> + val targets = channels + .filter { it.level.precision <= 5 } + .map { it.geohash } + .distinct() + targets to schedule + }.collectLatest { (targets, schedule) -> + if (targets.isEmpty()) return@collectLatest + while (true) { + val waitMs = if (schedule.presenceHeartbeatMaxMs > schedule.presenceHeartbeatMinMs) { + random.nextLong( + schedule.presenceHeartbeatMinMs, + schedule.presenceHeartbeatMaxMs + 1 + ) + } else { + schedule.presenceHeartbeatMinMs + } + delay(waitMs) + + // Send every target in one wake window; do not spread the batch over seconds. + targets.forEach { geohash -> + try { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) + NostrRelayManager.getInstance(application).sendEventToGeohash( + event, + geohash, + includeDefaults = false, + nRelays = 5 + ) + } catch (e: Exception) { + Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}") + } + } + } + } + } + } + + private fun dispatch(event: (Handlers) -> Unit) { + val current = handlers + if (current != null) { + event(current) + return + } + synchronized(lock) { + if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.removeFirst() + pendingEvents.addLast(event) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index f50d65cc..20a1ce18 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.* import okhttp3.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.min import kotlin.math.pow @@ -25,11 +26,15 @@ class NostrRelayManager private constructor() { val shared = NostrRelayManager() private const val TAG = "NostrRelayManager" + private const val MAX_QUEUED_EVENTS = 500 + const val OWNER_LEGACY = "legacy" + const val OWNER_BACKGROUND = "background" /** * Get instance for Android compatibility (context-aware calls) */ fun getInstance(context: android.content.Context): NostrRelayManager { + shared.appContext = context.applicationContext return shared } @@ -82,6 +87,8 @@ class NostrRelayManager private constructor() { // Internal state private val relaysList = mutableListOf() private val connections = ConcurrentHashMap() + private val reconnectJobs = ConcurrentHashMap() + private val desiredConnected = AtomicBoolean(false) private val subscriptions = ConcurrentHashMap>() // relay URL -> subscription IDs private val messageHandlers = ConcurrentHashMap Unit>() @@ -97,14 +104,19 @@ class NostrRelayManager private constructor() { val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays val createdAt: Long = System.currentTimeMillis(), - val originGeohash: String? = null // used for logging and grouping + val originGeohash: String? = null, // used for logging and grouping + val owner: String = OWNER_LEGACY ) // Event deduplication system private val eventDeduplicator = NostrEventDeduplicator.getInstance() // Message queue for reliability - private val messageQueue = mutableListOf>>() + private data class QueuedEvent( + val event: NostrEvent, + val pendingRelayUrls: MutableSet + ) + private val messageQueue = mutableListOf() private val messageQueueLock = Any() // Coroutine scope for background operations @@ -112,7 +124,9 @@ class NostrRelayManager private constructor() { // Subscription validation timer private var subscriptionValidationJob: Job? = null - private val SUBSCRIPTION_VALIDATION_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS // 30 seconds + private val powerManager: com.bitchat.android.mesh.PowerManager? + get() = appContext?.let { com.bitchat.android.mesh.PowerManager.getInstance(it) } + @Volatile private var appContext: android.content.Context? = null // OkHttp client for WebSocket connections (via provider to honor Tor) private val httpClient: OkHttpClient @@ -162,7 +176,8 @@ class NostrRelayManager private constructor() { id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, includeDefaults: Boolean = false, - nRelays: Int = 5 + nRelays: Int = 5, + owner: String = OWNER_LEGACY ): String { ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults) val relayUrls = getRelaysForGeohash(geohash) @@ -170,7 +185,8 @@ class NostrRelayManager private constructor() { filter = filter, id = id, handler = handler, - targetRelayUrls = relayUrls + targetRelayUrls = relayUrls, + owner = owner ).also { // update origin geohash for this subscription activeSubscriptions[it]?.let { sub -> @@ -204,6 +220,7 @@ class NostrRelayManager private constructor() { } updateRelaysList() + if (!desiredConnected.get()) return scope.launch { relayUrls.forEach { relayUrl -> launch { @@ -239,6 +256,7 @@ class NostrRelayManager private constructor() { * Connect to all configured relays */ fun connect() { + desiredConnected.set(true) Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays") scope.launch { @@ -258,18 +276,26 @@ class NostrRelayManager private constructor() { */ fun disconnect() { Log.i(TAG, "Disconnecting from all Nostr relays") + desiredConnected.set(false) // Stop subscription validation stopSubscriptionValidation() + reconnectJobs.values.forEach { it.cancel() } + reconnectJobs.clear() - connections.values.forEach { webSocket -> + val sockets = connections.values.toList() + connections.clear() + sockets.forEach { webSocket -> webSocket.close(1000, "Manual disconnect") } - connections.clear() - // Clear subscriptions + // Preserve logical subscriptions for controlled resets, but forget per-socket state. subscriptions.clear() - + relaysList.forEach { + it.isConnected = false + it.nextReconnectTime = null + } + updateRelaysList() updateConnectionStatus() } @@ -281,7 +307,8 @@ class NostrRelayManager private constructor() { // Add to queue for reliability synchronized(messageQueueLock) { - messageQueue.add(Pair(event, targetRelays)) + if (messageQueue.size >= MAX_QUEUED_EVENTS) messageQueue.removeAt(0) + messageQueue.add(QueuedEvent(event, targetRelays.toMutableSet())) } // Attempt immediate send @@ -289,7 +316,9 @@ class NostrRelayManager private constructor() { targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - sendToRelay(event, webSocket, relayUrl) + if (sendToRelay(event, webSocket, relayUrl)) { + markQueuedRelayDelivered(event.id, relayUrl) + } } } } @@ -303,14 +332,16 @@ class NostrRelayManager private constructor() { filter: NostrFilter, id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, - targetRelayUrls: List? = null + targetRelayUrls: List? = null, + owner: String = OWNER_LEGACY ): String { // Store subscription info for persistent tracking val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, handler = handler, - targetRelayUrls = targetRelayUrls?.toSet() + targetRelayUrls = targetRelayUrls?.toSet(), + owner = owner ) activeSubscriptions[id] = subscriptionInfo @@ -386,20 +417,28 @@ class NostrRelayManager private constructor() { } } } + + fun unsubscribeOwner(owner: String) { + activeSubscriptions.values + .filter { it.owner == owner } + .map { it.id } + .forEach(::unsubscribe) + } /** * Manually retry connection to a specific relay */ fun retryConnection(relayUrl: String) { val relay = relaysList.find { it.url == relayUrl } ?: return + desiredConnected.set(true) // Reset reconnection attempts relay.reconnectAttempts = 0 relay.nextReconnectTime = null // Disconnect if connected - connections[relayUrl]?.close(1000, "Manual retry") - connections.remove(relayUrl) + reconnectJobs.remove(relayUrl)?.cancel() + connections.remove(relayUrl)?.close(1000, "Manual retry") // Attempt immediate reconnection scope.launch { @@ -412,6 +451,7 @@ class NostrRelayManager private constructor() { * This will automatically restore all subscriptions when reconnected */ fun resetAllConnections() { + val shouldReconnect = desiredConnected.get() disconnect() // Reset all relay states @@ -421,8 +461,8 @@ class NostrRelayManager private constructor() { relay.lastError = null } - // Reconnect - subscriptions will be automatically restored in onOpen - connect() + // Reconnect only when connectivity was desired before the controlled reset. + if (shouldReconnect) connect() } /** @@ -546,8 +586,12 @@ class NostrRelayManager private constructor() { stopSubscriptionValidation() // Stop any existing validation subscriptionValidationJob = scope.launch { - while (isActive) { - delay(SUBSCRIPTION_VALIDATION_INTERVAL) + while (isActive && desiredConnected.get()) { + val validationInterval = powerManager?.profile?.value + ?.nostr?.subscriptionValidationMs + ?: com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS + delay(validationInterval) + if (!desiredConnected.get()) break try { val report = validateSubscriptionConsistency() @@ -587,6 +631,7 @@ class NostrRelayManager private constructor() { // MARK: - Private Methods private suspend fun connectToRelay(urlString: String) { + if (!desiredConnected.get()) return // Skip if we already have a connection if (connections.containsKey(urlString)) { return @@ -598,16 +643,22 @@ class NostrRelayManager private constructor() { .build() val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString)) - connections[urlString] = webSocket + val existing = connections.putIfAbsent(urlString, webSocket) + if (existing != null) { + webSocket.close(1000, "Duplicate connection") + } else if (!desiredConnected.get()) { + connections.remove(urlString, webSocket) + webSocket.close(1000, "Connection no longer desired") + } } catch (e: Exception) { Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}") - handleDisconnection(urlString, e) + handleConnectionCreationFailure(urlString, e) } } - private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { - try { + private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean { + return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) @@ -617,11 +668,26 @@ class NostrRelayManager private constructor() { val relay = relaysList.find { it.url == relayUrl } relay?.messagesSent = (relay?.messagesSent ?: 0) + 1 updateRelaysList() + true } else { Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed") + false } } catch (e: Exception) { Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}") + false + } + } + + private fun markQueuedRelayDelivered(eventId: String, relayUrl: String) { + synchronized(messageQueueLock) { + val iterator = messageQueue.iterator() + while (iterator.hasNext()) { + val queued = iterator.next() + if (queued.event.id != eventId) continue + queued.pendingRelayUrls.remove(relayUrl) + if (queued.pendingRelayUrls.isEmpty()) iterator.remove() + } } } @@ -690,12 +756,22 @@ class NostrRelayManager private constructor() { } } - private fun handleDisconnection(relayUrl: String, error: Throwable) { - connections.remove(relayUrl) - // NOTE: Don't remove subscriptions here - keep them for restoration on reconnection - // subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss - + private fun handleDisconnection(relayUrl: String, webSocket: WebSocket, error: Throwable) { + // A close/failure from an intentionally closed or replaced socket is stale. In particular, + // it must not remove a newer socket or schedule a reconnect after manual disconnect. + if (!connections.remove(relayUrl, webSocket)) return + subscriptions.remove(relayUrl) + handleCurrentDisconnection(relayUrl, error) + } + + private fun handleConnectionCreationFailure(relayUrl: String, error: Throwable) { + if (!desiredConnected.get()) return + handleCurrentDisconnection(relayUrl, error) + } + + private fun handleCurrentDisconnection(relayUrl: String, error: Throwable) { updateRelayStatus(relayUrl, false, error) + if (!desiredConnected.get()) return // Check if this is a DNS error val errorMessage = error.message?.lowercase() ?: "" @@ -730,11 +806,13 @@ class NostrRelayManager private constructor() { Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})") - // Schedule reconnection - scope.launch { + reconnectJobs.remove(relayUrl)?.cancel() + val reconnectJob = scope.launch { delay(backoffInterval) - connectToRelay(relayUrl) + if (desiredConnected.get()) connectToRelay(relayUrl) } + reconnectJobs[relayUrl] = reconnectJob + reconnectJob.invokeOnCompletion { reconnectJobs.remove(relayUrl, reconnectJob) } } private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) { @@ -808,25 +886,32 @@ class NostrRelayManager private constructor() { private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { + if (!desiredConnected.get() || connections[relayUrl] !== webSocket) { + webSocket.close(1000, "Stale connection") + return + } + reconnectJobs.remove(relayUrl)?.cancel() Log.i(TAG, "Connected to Nostr relay: $relayUrl") updateRelayStatus(relayUrl, true) // Restore all active subscriptions for this relay restoreSubscriptionsForRelay(relayUrl, webSocket) - // Process any queued messages for this relay - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val (event, targetRelays) = iterator.next() - if (relayUrl in targetRelays) { - sendToRelay(event, webSocket, relayUrl) - } + // Process only events still pending for this relay, outside the queue lock. + val queuedForRelay = synchronized(messageQueueLock) { + messageQueue + .filter { relayUrl in it.pendingRelayUrls } + .map { it.event } + } + queuedForRelay.forEach { event -> + if (sendToRelay(event, webSocket, relayUrl)) { + markQueuedRelayDelivered(event.id, relayUrl) } } } override fun onMessage(webSocket: WebSocket, text: String) { + if (connections[relayUrl] !== webSocket) return handleMessage(text, relayUrl) } @@ -837,12 +922,12 @@ class NostrRelayManager private constructor() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason") val error = Exception("WebSocket closed: $code $reason") - handleDisconnection(relayUrl, error) + handleDisconnection(relayUrl, webSocket, error) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}") - handleDisconnection(relayUrl, t) + handleDisconnection(relayUrl, webSocket, t) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 1a8514ab..601e885c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -11,7 +11,8 @@ import kotlinx.coroutines.launch */ class NostrSubscriptionManager( private val application: Application, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val owner: String = NostrRelayManager.OWNER_LEGACY ) { companion object { private const val TAG = "NostrSubscriptionManager" } @@ -23,7 +24,7 @@ class NostrSubscriptionManager( fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) - relayManager.subscribe(filter, id, handler) + relayManager.subscribe(filter, id, handler, owner = owner) } } @@ -31,7 +32,15 @@ class NostrSubscriptionManager( fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner + ) } } @@ -39,9 +48,21 @@ class NostrSubscriptionManager( fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner + ) } } fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } + + fun unsubscribeAllOwned() { + scope.launch { runCatching { relayManager.unsubscribeOwner(owner) } } + } } diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index f4ca9f74..30a641d0 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -56,6 +56,8 @@ object AppShutdownCoordinator { // Stop mesh (best-effort) try { mesh?.stopServices() } catch (_: Exception) { } + try { com.bitchat.android.nostr.NostrRelayManager.shared.disconnect() } catch (_: Exception) { } + try { com.bitchat.android.mesh.PowerManager.getInstance(app).shutdown() } catch (_: Exception) { } // Stop Tor temporarily (do not change user setting) val torProvider = ArtiTorManager.getInstance() diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index d9ad2d7f..1286988f 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -19,8 +19,8 @@ import com.bitchat.android.mesh.BluetoothMeshService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch class MeshForegroundService : Service() { @@ -115,6 +115,7 @@ class MeshForegroundService : Service() { private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false private var isShuttingDown: Boolean = false + private var lastNotifiedPeerCount: Int? = null override fun onCreate() { super.onCreate() @@ -131,6 +132,16 @@ class MeshForegroundService : Service() { MeshServiceHolder.attach(created) } MeshServiceHolder.getUnifiedOrCreate(applicationContext) + + // Notification content is driven by peer-state changes, not a permanent timer. + updateJob = scope.launch { + com.bitchat.android.services.AppStateStore.peers + .map { peers -> peers.distinct().size } + .distinctUntilChanged() + .collect { + if (isInForeground) updateNotification(force = false) + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -175,9 +186,11 @@ class MeshForegroundService : Service() { ACTION_UPDATE_NOTIFICATION -> { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val n = buildNotification(getUnifiedActivePeerCount()) + val count = getUnifiedActivePeerCount() + val n = buildNotification(count) startForegroundCompat(n) isInForeground = true + lastNotifiedPeerCount = count } else { updateNotification(force = true) } @@ -190,32 +203,11 @@ class MeshForegroundService : Service() { // Promote exactly once when eligible, otherwise stay background (or stop) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val notification = buildNotification(getUnifiedActivePeerCount()) + val count = getUnifiedActivePeerCount() + val notification = buildNotification(count) startForegroundCompat(notification) isInForeground = true - } - - // Periodically refresh the notification with live network size - if (updateJob == null) { - updateJob = scope.launch { - while (isActive) { - // Retry enabling mesh/foreground once permissions become available - ensureMeshStarted() - val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() - if (eligible) { - // Only update the notification; do not re-call startForeground() - updateNotification(force = false) - } else { - // If disabled or perms missing, ensure we are not in foreground and clear notif - if (isInForeground) { - try { stopForeground(false) } catch (_: Exception) { } - isInForeground = false - } - notificationManager.cancel(NOTIFICATION_ID) - } - delay(5000) - } - } + lastNotifiedPeerCount = count } return START_STICKY @@ -229,19 +221,9 @@ class MeshForegroundService : Service() { android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}") } - val bleEnabled = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) - } catch (_: Exception) { - true - } - if (!bleEnabled) { - try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { } - return - } - if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") - val service = MeshServiceHolder.getOrCreate(applicationContext) + val service = MeshServiceHolder.getUnifiedOrCreate(applicationContext) service.startServices() } catch (e: Exception) { android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") @@ -254,14 +236,17 @@ class MeshForegroundService : Service() { return } val count = getUnifiedActivePeerCount() - val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { - notificationManager.notify(NOTIFICATION_ID, notification) + if (lastNotifiedPeerCount != count) { + notificationManager.notify(NOTIFICATION_ID, buildNotification(count)) + lastNotifiedPeerCount = count + } } else if (force) { // If disabled and forced, make sure to remove any prior foreground state try { stopForeground(false) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) isInForeground = false + lastNotifiedPeerCount = null } } diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c7971eb8..c88e1bc5 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -17,6 +17,8 @@ object AppStateStore { private val peerIdsByTransport = mutableMapOf>() // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. private val directPeerIdsByTransport = mutableMapOf>() + private val _directPeers = MutableStateFlow>(emptySet()) + val directPeers: StateFlow> = _directPeers.asStateFlow() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -69,20 +71,25 @@ object AppStateStore { fun setTransportDirectPeers(transportId: String, ids: Collection) { synchronized(this) { directPeerIdsByTransport[transportId] = ids.toSet() + publishDirectPeersLocked() } } fun clearTransportDirectPeers(transportId: String) { synchronized(this) { directPeerIdsByTransport.remove(transportId) + publishDirectPeersLocked() } } /** Union of direct peers across all transports. */ - fun getDirectPeers(): Set { - synchronized(this) { - return directPeerIdsByTransport.values.flatten().toSet() - } + fun getDirectPeers(): Set = _directPeers.value + + private fun publishDirectPeersLocked() { + _directPeers.value = directPeerIdsByTransport.values + .asSequence() + .flatten() + .toSet() } fun addPublicMessage(msg: BitchatMessage) { @@ -210,6 +217,7 @@ object AppStateStore { peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() + _directPeers.value = emptySet() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() 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 4c6d5cdc..87e6104e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -340,6 +340,7 @@ class ChatViewModel( } override fun onCleared() { + geohashViewModel.shutdownUiSubscriptions() super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } @@ -358,34 +359,9 @@ class ChatViewModel( */ private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) { try { - val repoField = GeohashViewModel::class.java.getDeclaredField("repo") - repoField.isAccessible = true - val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository - val gh = repo.getConversationGeohash(convKey) + val gh = geohashViewModel.conversationGeohash(convKey) if (!gh.isNullOrEmpty()) { - val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager") - subMgrField.isAccessible = true - val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication()) - val subId = "geo-dm-$gh" - val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId") - currentDmSubField.isAccessible = true - val currentId = currentDmSubField.get(geohashViewModel) as String? - if (currentId != subId) { - (currentId)?.let { subMgr.unsubscribe(it) } - currentDmSubField.set(geohashViewModel, subId) - subMgr.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = subId, - handler = { event -> - val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler") - dmHandlerField.isAccessible = true - val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler - dmHandler.onGiftWrap(event, gh, identity) - } - ) - } + com.bitchat.android.nostr.NostrBackgroundRuntime.ensureConversationDm(gh) } } catch (e: Exception) { Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index d94e5fff..2e74f3e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.NostrDirectMessageHandler +import com.bitchat.android.nostr.NostrBackgroundRuntime import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager @@ -23,11 +24,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Date -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.Dispatchers -import java.security.SecureRandom -import kotlin.random.asKotlinRandom class GeohashViewModel( application: Application, @@ -39,19 +37,21 @@ class GeohashViewModel( private val notificationManager: NotificationManager ) : AndroidViewModel(application), DefaultLifecycleObserver { - companion object { - private const val TAG = "GeohashViewModel" - private val secureRandom = SecureRandom().asKotlinRandom() - } + companion object { private const val TAG = "GeohashViewModel" } private val repo = GeohashRepository(application, state, dataManager) - private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) + private val uiSubscriptionOwner = "geohash-ui-${System.identityHashCode(this)}" + private val subscriptionManager = NostrSubscriptionManager( + application, + viewModelScope, + owner = uiSubscriptionOwner + ) private val geohashMessageHandler = GeohashMessageHandler( application = application, state = state, messageManager = messageManager, repo = repo, - scope = viewModelScope, + scope = NostrBackgroundRuntime.eventScope, dataManager = dataManager ) private val dmHandler = NostrDirectMessageHandler( @@ -59,18 +59,14 @@ class GeohashViewModel( state = state, privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, - scope = viewModelScope, + scope = NostrBackgroundRuntime.eventScope, repo = repo, dataManager = dataManager ) - // Live channel message stream (kind 20000). Low-volume; kept alive in the background. - private var currentGeohashMsgSubId: String? = null // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. private var currentGeohashPresenceSubId: String? = null - private var currentDmSubId: String? = null private var geoTimer: Job? = null - private var globalPresenceJob: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private val activeSamplingGeohashes = mutableSetOf() @@ -82,23 +78,21 @@ class GeohashViewModel( val selectedLocationChannel: StateFlow = state.selectedLocationChannel fun initialize() { - subscriptionManager.connect() // Observe process lifecycle to manage background sampling kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } - val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) - if (identity != null) { - // Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below. - subscriptionManager.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = "chat-messages", - handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity) - ) - } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + NostrBackgroundRuntime.attachHandlers( + NostrBackgroundRuntime.Handlers( + accountDm = { event, identity -> dmHandler.onGiftWrap(event, "", identity) }, + geohashMessage = { event, geohash -> geohashMessageHandler.onEvent(event, geohash) }, + geohashDm = { event, geohash, identity -> + dmHandler.onGiftWrap(event, geohash, identity) + } + ) + ) viewModelScope.launch { locationChannelManager?.selectedChannel?.collect { channel -> state.setSelectedLocationChannel(channel) @@ -110,10 +104,6 @@ class GeohashViewModel( state.setIsTeleported(teleported) } } - - // Start global presence heartbeat loop - startGlobalPresenceHeartbeat() - } catch (e: Exception) { Log.e(TAG, "Failed to initialize location channel state: ${e.message}") state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) @@ -121,77 +111,17 @@ class GeohashViewModel( } } - private fun startGlobalPresenceHeartbeat() { - globalPresenceJob?.cancel() - globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - // Reactively restart heartbeat whenever available channels change - locationChannelManager?.availableChannels?.collectLatest { channels -> - // Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5 - val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash } - - if (targetGeohashes.isNotEmpty()) { - // Enter heartbeat loop for this set of channels - // If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately - while (true) { - // Randomize loop interval (40-80s, average 60s) - val loopInterval = secureRandom.nextLong(40000L, 80000L) - var timeSpent = 0L - - try { - Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels") - targetGeohashes.forEach { geohash -> - // Decorrelate individual broadcasts with random delay (1s-5s) - val stepDelay = secureRandom.nextLong(1000L, 10000L) - delay(stepDelay) - timeSpent += stepDelay - - broadcastPresence(geohash) - } - } catch (e: Exception) { - Log.w(TAG, "Global presence heartbeat error: ${e.message}") - } - - // Wait remaining time to satisfy target average cadence - val remaining = loopInterval - timeSpent - if (remaining > 0) { - delay(remaining) - } else { - delay(10000L) // Minimum guard delay - } - } - } - } - } - } - fun panicReset() { repo.clearAll() GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() - subscriptionManager.disconnect() - currentGeohashMsgSubId = null + subscriptionManager.unsubscribeAllOwned() currentGeohashPresenceSubId = null - currentDmSubId = null activeChannelGeohash = null geoTimer?.cancel() geoTimer = null - globalPresenceJob?.cancel() - globalPresenceJob = null try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} - initialize() - } - - private suspend fun broadcastPresence(geohash: String) { - try { - val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) - val relayManager = NostrRelayManager.getInstance(getApplication()) - // Presence is lightweight, send to geohash relays - relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5) - Log.v(TAG, "💓 Sent presence heartbeat for $geohash") - } catch (e: Exception) { - Log.w(TAG, "Failed to send presence for $geohash: ${e.message}") - } + NostrBackgroundRuntime.resetSubscriptions() } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { @@ -331,15 +261,14 @@ class GeohashViewModel( fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + fun conversationGeohash(conversationKey: String): String? = repo.getConversationGeohash(conversationKey) fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = nostrPeerColorSeed(pubkeyHex) private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null - currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null } currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } - currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null } when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { @@ -368,17 +297,11 @@ class GeohashViewModel( startGeoParticipantsTimer() - // Chat message stream (kind 20000) is low-volume; keep it alive even when - // backgrounded so geohash messages still arrive. - subscribeChannelMessages(channel.channel.geohash) // Presence heartbeat firehose (kind 20001) is the high-volume data hog; only // run it in the foreground. It is restored in onStart() and torn down in onStop(). if (isAppInForeground()) { subscribeChannelPresence(channel.channel.geohash) } - // Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is - // kept alive in the background so geohash DMs still arrive. - subscribeChannelDM(channel.channel.geohash) } null -> { Log.d(TAG, "📡 No channel selected") @@ -388,21 +311,6 @@ class GeohashViewModel( } } - /** - * Subscribe to the chat message stream (kind 20000) for a geohash channel. - * Low-volume; kept alive in the background so messages keep arriving. - */ - private fun subscribeChannelMessages(geohash: String) { - val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId - subscriptionManager.subscribeGeohashMessages( - geohash = geohash, - sinceMs = System.currentTimeMillis() - 3600000L, - limit = 200, - id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } - ) - } - /** * Subscribe to the presence heartbeat firehose (kind 20001) for a geohash channel. * High-volume; only used to refresh the participant list, so it is torn down in @@ -419,25 +327,6 @@ class GeohashViewModel( ) } - /** - * Subscribe to gift-wrap DMs for a geohash channel's derived identity. - * Lightweight (filtered to our pubkey); kept alive in the background. - */ - private fun subscribeChannelDM(geohash: String) { - viewModelScope.launch { - val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId - subscriptionManager.subscribeGiftWraps( - pubkey = dmIdentity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = dmSubId, - handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } - ) - // Also register alias in global registry for routing convenience - GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) - } - } - private fun startGeoParticipantsTimer() { geoTimer = viewModelScope.launch { while (repo.getCurrentGeohash() != null) { @@ -449,6 +338,11 @@ class GeohashViewModel( override fun onCleared() { super.onCleared() + shutdownUiSubscriptions() + } + + fun shutdownUiSubscriptions() { + subscriptionManager.unsubscribeAllOwned() kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } @@ -465,10 +359,6 @@ class GeohashViewModel( if (repo.getCurrentGeohash() != null && geoTimer?.isActive != true) { startGeoParticipantsTimer() } - // Resume the global presence heartbeat - if (globalPresenceJob?.isActive != true) { - startGlobalPresenceHeartbeat() - } } override fun onStop(owner: LifecycleOwner) { @@ -478,12 +368,9 @@ class GeohashViewModel( currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } // Drop geohash sampling subscriptions activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") } - // Stop broadcasting presence heartbeats - globalPresenceJob?.cancel(); globalPresenceJob = null // Stop participant-refresh polling geoTimer?.cancel(); geoTimer = null - // NOTE: gift-wrap DM subscriptions (per-geohash + global "chat-messages") are intentionally - // left active so direct messages still arrive while backgrounded. + // Process-owned message and DM subscriptions remain active. } private fun performSubscribeSampling(geohash: String) { diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4905c927..2be47a99 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -90,7 +90,7 @@ object AppConstants { const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 - const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 + const val MAX_CONNECTIONS_ULTRA_LOW: Int = 8 } object Nostr { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt index c3039327..9688f04f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt @@ -24,10 +24,9 @@ class SyncedSocket( private val outputStream: DataOutputStream companion object { - // Both peers exchange keep-alive frames every ~2s while connected, so a read that - // stalls well beyond that means the link is dead (half-open). Time out so the read - // loop can detect it and trigger disconnection instead of blocking forever. - const val DEFAULT_READ_TIMEOUT_MS = 15_000 + // Critical-background peers send at most every 30s. Tolerate three missed frames so a + // faster local profile cannot drop a healthy, slower remote peer merely for being idle. + const val DEFAULT_READ_TIMEOUT_MS = 90_000 } init { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt index f2ec4cfa..1c2b7e5f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -6,12 +6,10 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean @@ -68,24 +66,20 @@ object WifiAwareController { Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}") } setEnabled(enabledByDefault) - // Start background poller for debug surfacing - scope.launch { - while (isActive) { - try { - val s = service - if (s != null) { - _connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip - _knownPeers.value = s.getPeerNicknames() - _discoveredPeers.value = s.getDiscoveredPeerIds() - } else { - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() - } - } catch (_: Exception) { } - delay(1000) - } - } + } + + internal fun publishDebugSnapshot( + connected: Map, + known: Map, + discovered: Set + ) { + _connectedPeers.value = connected + _knownPeers.value = known + _discoveredPeers.value = discovered + } + + private fun clearDebugSnapshot() { + publishDebugSnapshot(emptyMap(), emptyMap(), emptySet()) } fun setEnabled(value: Boolean) { @@ -202,9 +196,7 @@ object WifiAwareController { } try { stopped?.stopServices() } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() + clearDebugSnapshot() try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} } @@ -214,9 +206,7 @@ object WifiAwareController { service = null _running.value = false try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() + clearDebugSnapshot() } } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index d6cbf981..cd83e898 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -76,10 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private const val CLIENT_SOCKET_ATTEMPTS = 3 private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L - // Discovery freshness window for reconnection maintenance - private const val DISCOVERY_STALE_MS = 5L * 60 * 1000 - private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000 - private const val DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS = 90L * 1000 private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:" } @@ -89,6 +85,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val powerManager = com.bitchat.android.mesh.PowerManager.getInstance(context.applicationContext) private val wifiTransport = WifiAwareTransport() private lateinit var meshCore: MeshCore private lateinit var fragmentingSender: FragmentingPacketSender @@ -175,6 +172,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor hooks = MeshCore.Hooks( onMessageReceived = { message -> handleMessageReceived(message) }, onAnnounceProcessed = { routed, _ -> + publishControllerDebugSnapshot() routed.peerID?.let { pid -> try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } @@ -653,6 +651,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val now = System.currentTimeMillis() discoveredTimestamps[peerId] = now lastDiscoveryActivityAt.set(now) + publishControllerDebugSnapshot() } @RequiresApi(Build.VERSION_CODES.Q) @@ -669,7 +668,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false val lastRefresh = lastDiscoveryRefreshAt.get() - if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false + val minRefreshMs = powerManager.profile.value.wifiAware.discoverySessionRefreshMinMs + if ((now - lastRefresh) < minRefreshMs) return false if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false Log.i(TAG, "Refreshing Wi-Fi Aware discovery sessions ($reason)") @@ -684,7 +684,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor serviceScope.launch { while (isActive) { try { - delay(15_000) // Check every 15 seconds + val schedule = powerManager.profile.value.wifiAware + delay(schedule.connectionMaintenanceMs) if (!isActive) break val now = System.currentTimeMillis() @@ -692,18 +693,19 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // 0. Prune stale discovery entries. PeerHandles become invalid when the // discovery sessions restart, so we must not keep pinging old handles forever. val staleIds = discoveredTimestamps.filter { (id, ts) -> - (now - ts) >= DISCOVERY_STALE_MS && !connectionTracker.isConnected(id) + (now - ts) >= schedule.discoveryStaleMs && !connectionTracker.isConnected(id) }.keys.toSet() if (staleIds.isNotEmpty()) { staleIds.forEach { discoveredTimestamps.remove(it) } handleToPeerId.entries.removeIf { it.value in staleIds } staleIds.forEach { subscribeHandles.remove(it) } staleIds.forEach { publishHandles.remove(it) } + publishControllerDebugSnapshot() } // 1. Identify peers that are discovered (recently seen) but not currently connected val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> - (now - ts) < DISCOVERY_STALE_MS // Seen in last 5 minutes + (now - ts) < schedule.discoveryStaleMs }.keys // 2. Filter out those who are already connected @@ -753,7 +755,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> { refreshDiscoverySessions("missing peer handle", now) } - recentDiscovered.isEmpty() && idleFor >= DISCOVERY_IDLE_REFRESH_MS -> { + recentDiscovered.isEmpty() && idleFor >= schedule.discoveryIdleRefreshMs -> { refreshDiscoverySessions("idle discovery", now) } } @@ -896,6 +898,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val synced = SyncedSocket(client) activeSocket = synced connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() // We only ever accept a single data socket per server request. Close the // listening ServerSocket now so it can't block a future re-serve (its // presence makes hasOpenServerSocket() true for the life of the process) @@ -964,30 +967,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor pubSession: PublishDiscoverySession, peerHandle: PeerHandle ) { - // TCP keep-alive pings - serviceScope.launch { - try { - while (connectionTracker.isConnected(peerId)) { - // write empty byte array effectively sends [4 bytes length=0] which is our ping - try { - client.write(ByteArray(0)) - } catch (_: IOException) { - // The write side is dead. Don't just stop pinging: actively tear down so the - // half-open socket stops counting as "connected" and maintenance can retry. - handlePeerDisconnection(peerId, client) - break - } - delay(2_000) - } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (connectionTracker.isConnected(peerId)) { - try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } - delay(20_000) - } + startProfiledKeepAlive(client, peerId) { msgId -> + pubSession.sendMessage(peerHandle, msgId, ByteArray(0)) } } @@ -1155,6 +1136,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val synced = SyncedSocket(sock) activeSocket = synced connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() clientSocketFailures.remove(peerId) try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} @@ -1199,28 +1181,50 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor peerId: String, peerHandle: PeerHandle ) { - // TCP keep-alive + startProfiledKeepAlive(sock, peerId) { msgId -> + subscribeSession?.sendMessage(peerHandle, msgId, ByteArray(0)) + } + } + + /** + * One coroutine per peer schedules both TCP and discovery deadlines. This avoids two + * independently waking jobs while still adapting after every send to the latest profile. + */ + private fun startProfiledKeepAlive( + socket: SyncedSocket, + peerId: String, + sendDiscovery: (Int) -> Unit + ) { serviceScope.launch { - try { - while (connectionTracker.isConnected(peerId)) { + var discoveryMessageId = 0 + var nextTcpAt = 0L + var nextDiscoveryAt = 0L + while (connectionTracker.isConnected(peerId)) { + val now = android.os.SystemClock.elapsedRealtime() + val schedule = powerManager.profile.value.wifiAware + + if (now >= nextTcpAt) { try { - sock.write(ByteArray(0)) + socket.write(ByteArray(0)) } catch (_: IOException) { - // The write side is dead. Tear down so the half-open socket stops counting - // as "connected" and maintenance can retry instead of silently stalling. - handlePeerDisconnection(peerId, sock) + handlePeerDisconnection(peerId, socket) break } - delay(2_000) + nextTcpAt = now + schedule.tcpKeepAliveMs } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (connectionTracker.isConnected(peerId)) { - try { subscribeSession?.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } - delay(20_000) + + if (now >= nextDiscoveryAt) { + try { + sendDiscovery(discoveryMessageId++) + } catch (_: Exception) { + // The TCP data path remains usable; discovery maintenance will recover its + // session independently without dropping this idle connection. + } + nextDiscoveryAt = now + schedule.discoveryKeepAliveMs + } + + val nextWakeAt = minOf(nextTcpAt, nextDiscoveryAt) + delay((nextWakeAt - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(250L)) } } } @@ -1297,6 +1301,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { } try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { } + publishControllerDebugSnapshot() Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}") } @@ -1401,6 +1406,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } } // Else: socket replaced or inactive; do not remove peer/session, as a new socket has likely taken over + publishControllerDebugSnapshot() } } @@ -1611,6 +1617,16 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor fun getDiscoveredPeerIds(): Set = (handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet() + private fun publishControllerDebugSnapshot() { + try { + com.bitchat.android.wifiaware.WifiAwareController.publishDebugSnapshot( + connected = getDeviceAddressToPeerMapping(), + known = getPeerNicknames(), + discovered = getDiscoveredPeerIds() + ) + } catch (_: Exception) { } + } + /** * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. */ diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt new file mode 100644 index 00000000..8d4df343 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt @@ -0,0 +1,102 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PowerProfileResolverTest { + + @Test + fun `background normal without peers scans one second per minute`() { + val profile = resolve(battery = 80, background = true, peers = false) + + assertEquals(PowerManager.BatteryBand.NORMAL, profile.batteryBand) + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(59_000L, profile.ble.scanOffMs) + assertFalse(profile.ble.continuousScan) + assertEquals(8, profile.ble.maxConnections) + assertEquals(60_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `any direct transport peer accelerates background BLE discovery to thirty seconds`() { + val profile = resolve(battery = 80, background = true, peers = true) + + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(29_000L, profile.ble.scanOffMs) + } + + @Test + fun `background low profile uses selected wifi and nostr cadence`() { + val profile = resolve(battery = 20, background = true) + + assertEquals(PowerManager.BatteryBand.LOW, profile.batteryBand) + assertEquals(20_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(600_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(900_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(600_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(600_000L, profile.nostr.subscriptionValidationMs) + assertEquals(120_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `background critical profile uses slowest wifi and nostr cadence without reducing connections`() { + val profile = resolve(battery = 10, background = true, peers = true) + + assertEquals(PowerManager.BatteryBand.CRITICAL, profile.batteryBand) + assertEquals(30_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(900_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(1_200_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(900_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(900_000L, profile.nostr.subscriptionValidationMs) + assertEquals(300_000L, profile.meshAnnouncementIntervalMs) + assertEquals(8, profile.ble.maxConnections) + } + + @Test + fun `foreground normal preserves balanced BLE cadence`() { + val profile = resolve(battery = 80, background = false) + + assertEquals(PowerManager.PowerMode.BALANCED, profile.mode) + assertEquals(8_000L, profile.ble.scanOnMs) + assertEquals(2_000L, profile.ble.scanOffMs) + assertEquals(5_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(30_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `foreground charging is performance but background charging stays power limited`() { + val foreground = PowerProfileResolver.resolve(80, true, false, false) + val background = PowerProfileResolver.resolve(80, true, true, false) + + assertEquals(PowerManager.PowerMode.PERFORMANCE, foreground.mode) + assertTrue(foreground.ble.continuousScan) + assertEquals(PowerManager.PowerMode.POWER_SAVER, background.mode) + assertFalse(background.ble.continuousScan) + assertEquals(59_000L, background.ble.scanOffMs) + } + + @Test + fun `battery thresholds are inclusive`() { + assertEquals(PowerManager.BatteryBand.NORMAL, resolve(21, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(20, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(11, true).batteryBand) + assertEquals(PowerManager.BatteryBand.CRITICAL, resolve(10, true).batteryBand) + } + + private fun resolve( + battery: Int, + background: Boolean, + peers: Boolean = false + ) = PowerProfileResolver.resolve( + batteryLevel = battery, + isCharging = false, + isBackground = background, + hasDirectPeers = peers + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 577823ac..bebae3eb 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -77,6 +77,10 @@ class AppStateStoreTest { setOf("ble-1", "wifi-1", "shared"), AppStateStore.getDirectPeers() ) + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.directPeers.value + ) } @Test