diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index c7cd6c19..b402fc88 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -23,6 +23,9 @@ class BitchatApplication : Application() { return } + // 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() @@ -77,6 +80,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 2f8653bf..a1b090da 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()) @@ -129,7 +129,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() @@ -306,9 +318,6 @@ class BluetoothConnectionManager( clientManager.stop() serverManager.stop() - // Stop power manager - powerManager.stop() - // Stop connection tracker connectionTracker.stop() @@ -495,54 +504,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 cc13d5df..498339ce 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) } } } @@ -661,13 +655,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 027066c7..cb6f278e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -131,7 +131,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 @@ -245,23 +244,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 */ @@ -827,8 +809,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 { @@ -850,8 +830,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) { } @@ -871,8 +849,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 0ec81c3e..0409ba7d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -21,7 +21,6 @@ import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -118,7 +117,6 @@ class MeshCore( var delegate: MeshDelegate? = null - private var announceJob: Job? = null private var isActive = false init { @@ -147,7 +145,6 @@ class MeshCore( fun startCore() { if (isActive) return isActive = true - startPeriodicBroadcastAnnounce() if (ownsGossipManager) { gossipSyncManager.start() } @@ -156,8 +153,6 @@ class MeshCore( fun stopCore() { if (!isActive) return isActive = false - announceJob?.cancel() - announceJob = null directPeers.clear() if (ownsGossipManager) { gossipSyncManager.stop() @@ -210,18 +205,6 @@ class MeshCore( return acceptedByLocalTransport || acceptedByBridgedTransport } - 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 bde744fd..4938d98c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -8,6 +8,16 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.noise.NoiseSession 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.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** * Feature-facing mesh service that hides local transport selection from the rest of the app. @@ -26,6 +36,10 @@ class UnifiedMeshService( private const val BLE_NDR_TRANSPORT_ID = "BLE" } + 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 @@ -51,14 +65,37 @@ 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 { + powerManager.profile + .map { profile -> + profile.meshAnnouncementIntervalMs to profile.hasDirectPeers + } + .distinctUntilChanged() + .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 (powerManager.profile.value.hasDirectPeers) 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/AccountResetCoordinator.kt b/app/src/main/java/com/bitchat/android/nostr/AccountResetCoordinator.kt new file mode 100644 index 00000000..2f9dadab --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/AccountResetCoordinator.kt @@ -0,0 +1,116 @@ +package com.bitchat.android.nostr + +import android.app.Application +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.services.MessageRouter +import com.bitchat.android.services.MessageRouterResetToken + +internal class AccountResetLease internal constructor( + internal val id: AccountResetLeaseId, + internal val transport: NostrTransport?, + internal val transportToken: NostrTransportResetToken?, + internal val router: MessageRouter?, + internal val routerToken: MessageRouterResetToken?, + internal val relay: NostrRelayManager, + internal val relayToken: RelayAccountResetToken +) { + internal var relayDiscarded = false + internal var completionAttempted = false +} + +/** + * Owns the cross-component account barrier used by panic and process exit. + * + * All production account resets enter here. This makes the individual reset + * tokens one composite lease and prevents an older panic from installing or + * starting a replacement mesh after a newer panic or quit has begun. + */ +internal object AccountResetCoordinator { + private val gate = AccountResetGate() + + fun begin( + application: Application, + terminal: Boolean = false + ): AccountResetLease? { + var result: AccountResetLease? = null + gate.begin(terminal = terminal) { id -> + val transport = NostrTransport.tryGetInstance() + val router = MessageRouter.tryGetInstance() + val transportToken = transport?.discardForAccountReset() + val routerToken = router?.discardForAccountReset() + + // Invalidate callbacks before removing relay subscriptions. The + // runtime call also cancels account-bound jobs when initialized. + NostrInboundAccountLifecycle.invalidate() + NostrBackgroundRuntime.invalidateAccount() + + val relay = NostrRelayManager.getInstance(application) + val relayToken = relay.beginAccountReset() + result = AccountResetLease( + id = id, + transport = transport, + transportToken = transportToken, + router = router, + routerToken = routerToken, + relay = relay, + relayToken = relayToken + ) + } ?: return null + return result + } + + /** + * Remove relay-owned state after the NDR runtime has quiesced. + */ + fun discardRelay(lease: AccountResetLease): Boolean { + var discarded = false + val owned = gate.runIfCurrent(lease.id) { + discarded = lease.relay.discardForAccountReset(lease.relayToken) + if (discarded) { + lease.relayDiscarded = true + } + } + return owned && discarded + } + + /** + * Install and start the replacement identity while this lease still owns + * the reset. + */ + fun complete( + lease: AccountResetLease, + installReplacement: () -> MeshService, + startReplacement: (MeshService) -> Unit + ): Boolean { + var reopened = false + val owned = gate.runIfCurrent(lease.id) { + if (!lease.relayDiscarded || lease.completionAttempted) { + return@runIfCurrent + } + lease.completionAttempted = true + val replacement = installReplacement() + lease.transport?.senderPeerID = replacement.myPeerID + lease.router?.installReplacementMeshForAccountReset(replacement) + + val relayReopened = + lease.relay.completeAccountReset(lease.relayToken) + val transportReopened = + relayReopened && + (lease.transportToken?.let { token -> + lease.transport?.completeAccountReset(token) == true + } ?: true) + val routerReopened = + transportReopened && + (lease.routerToken?.let { token -> + lease.router?.completeAccountReset(token) == true + } ?: true) + + if (routerReopened && transportReopened && relayReopened) { + NostrBackgroundRuntime.resetSubscriptions() + startReplacement(replacement) + reopened = true + } + } + return owned && reopened + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/AccountResetGate.kt b/app/src/main/java/com/bitchat/android/nostr/AccountResetGate.kt new file mode 100644 index 00000000..7726086e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/AccountResetGate.kt @@ -0,0 +1,50 @@ +package com.bitchat.android.nostr + +@JvmInline +internal value class AccountResetLeaseId(val epoch: Long) + +/** + * Serializes the short ownership-sensitive portions of account replacement. + * + * Destructive clearing may happen outside this gate after [begin]. Reopening + * transports and installing the replacement identity must happen through + * [runIfCurrent], so an older reset cannot mutate state after a newer one starts. + */ +internal class AccountResetGate { + private val lock = Any() + private var epoch = 0L + private var terminal = false + + fun begin(): AccountResetLeaseId = + checkNotNull(begin(terminal = false)) + + fun begin( + terminal: Boolean, + mutation: (AccountResetLeaseId) -> Unit = {} + ): AccountResetLeaseId? { + check(!Thread.holdsLock(lock)) { + "An account reset cannot begin from its own owned mutation" + } + return synchronized(lock) { + if (this.terminal) { + return@synchronized null + } + epoch += 1 + this.terminal = terminal + AccountResetLeaseId(epoch).also(mutation) + } + } + + fun runIfCurrent( + lease: AccountResetLeaseId, + mutation: () -> Unit + ): Boolean = + synchronized(lock) { + if (lease.epoch != epoch) { + false + } else { + mutation() + true + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index fd374772..402899f4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -3,10 +3,7 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MessageManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import java.util.Date @@ -18,11 +15,9 @@ import java.util.Date */ class GeohashMessageHandler( private val application: Application, - private val state: ChatState, - private val messageManager: MessageManager, private val repo: GeohashRepository, - private val scope: CoroutineScope, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val addChannelMessage: (String, BitchatMessage) -> Unit ) { companion object { private const val TAG = "GeohashMessageHandler" } @@ -31,6 +26,7 @@ class GeohashMessageHandler( private val seen = HashSet() private val max = 2000 + @Synchronized private fun dedupe(id: String): Boolean { if (seen.contains(id)) return true seen.add(id) @@ -42,6 +38,12 @@ class GeohashMessageHandler( return false } + @Synchronized + internal fun clearAccountState() { + processedIds.clear() + seen.clear() + } + internal fun onEvent( event: NostrEvent, subscribedGeohash: String, @@ -50,7 +52,7 @@ class GeohashMessageHandler( val accountContext = NostrInboundAccountLifecycle.contextFor(accountEpoch) ?: return - scope.launch(Dispatchers.Default + accountContext.receiveJob) { + accountContext.receiveScope.launch { try { if (!NostrInboundAccountLifecycle.isCurrent(accountEpoch)) { return@launch @@ -75,14 +77,6 @@ class GeohashMessageHandler( val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && event.content.trim().isEmpty() - val emitMessage = - event.kind != NostrKind.GEOHASH_PRESENCE && - !isTeleportPresence && - !NostrIdentityBridge.deriveIdentity( - subscribedGeohash, - application - ).publicKeyHex.equals(pubkey, true) - NostrInboundAccountLifecycle.runIfCurrent(accountEpoch) { if (dedupe(event.id)) return@runIfCurrent repo.updateParticipant( @@ -104,6 +98,13 @@ class GeohashMessageHandler( "nostr_${pubkey.take(16)}", pubkey ) + val emitMessage = + event.kind != NostrKind.GEOHASH_PRESENCE && + !isTeleportPresence && + !NostrIdentityBridge.deriveIdentity( + subscribedGeohash, + application + ).publicKeyHex.equals(pubkey, true) if (emitMessage) { val hasNonce = try { NostrProofOfWork.hasNonce(event) @@ -132,12 +133,14 @@ class GeohashMessageHandler( null } ) - messageManager.addChannelMessage( + addChannelMessage( "geo:$subscribedGeohash", msg ) } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "onEvent error: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index 3a7ced32..50c72cd4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -28,14 +28,17 @@ class GeohashRepository( // conversation key (e.g., "nostr_") -> source geohash it belongs to private val conversationGeohash: MutableMap = mutableMapOf() + @Synchronized fun setConversationGeohash(convKey: String, geohash: String) { if (geohash.isNotEmpty()) { conversationGeohash[convKey] = geohash } } + @Synchronized fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey] + @Synchronized fun findPubkeyByNickname(targetNickname: String): String? { return geoNicknames.entries.firstOrNull { (_, nickname) -> val base = nickname.split("#").firstOrNull() ?: nickname @@ -43,6 +46,7 @@ class GeohashRepository( }?.key } + @Synchronized fun findPubkeyByShortId(shortId: String): String? { // First check cached nicknames (fastest) var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) } @@ -66,6 +70,7 @@ class GeohashRepository( fun setCurrentGeohash(geo: String?) { currentGeohash = geo } fun getCurrentGeohash(): String? = currentGeohash + @Synchronized fun clearAll() { geohashParticipants.clear() geoNicknames.clear() @@ -76,6 +81,7 @@ class GeohashRepository( currentGeohash = null } + @Synchronized fun cacheNickname(pubkeyHex: String, nickname: String) { val lower = pubkeyHex.lowercase() val previous = geoNicknames[lower] @@ -85,8 +91,10 @@ class GeohashRepository( } } + @Synchronized fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()] + @Synchronized fun markTeleported(pubkeyHex: String) { val set = state.getTeleportedGeoValue().toMutableSet() val key = pubkeyHex.lowercase() @@ -97,10 +105,12 @@ class GeohashRepository( } } + @Synchronized fun isPersonTeleported(pubkeyHex: String): Boolean { return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) } + @Synchronized fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) { val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } // Cap to now: prevents future-timestamped events (clock skew / malicious created_at) @@ -117,6 +127,7 @@ class GeohashRepository( updateReactiveParticipantCounts() } + @Synchronized fun geohashParticipantCount(geohash: String): Int { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val participants = geohashParticipants[geohash] ?: return 0 @@ -130,6 +141,7 @@ class GeohashRepository( return participants.keys.count { !dataManager.isGeohashUserBlocked(it) } } + @Synchronized fun refreshGeohashPeople() { val geohash = currentGeohash if (geohash == null) { @@ -168,6 +180,7 @@ class GeohashRepository( state.setGeohashPeople(people) } + @Synchronized fun updateReactiveParticipantCounts() { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val counts = mutableMapOf() @@ -180,12 +193,15 @@ class GeohashRepository( state.setGeohashParticipantCounts(counts) } + @Synchronized fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { nostrKeyMapping[tempKeyOrPeer] = pubkeyHex } + @Synchronized fun getNostrKeyMapping(): Map = nostrKeyMapping.toMap() + @Synchronized fun displayNameForNostrPubkey(pubkeyHex: String): String { val suffix = pubkeyHex.takeLast(4) val lower = pubkeyHex.lowercase() @@ -203,6 +219,7 @@ class GeohashRepository( return "$nick#$suffix" } + @Synchronized fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { val lower = pubkeyHex.lowercase() val suffix = pubkeyHex.takeLast(4) @@ -234,6 +251,7 @@ class GeohashRepository( /** * Get display name for any geohash (not just current one) for header titles */ + @Synchronized fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { val lower = pubkeyHex.lowercase() val suffix = pubkeyHex.takeLast(4) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt new file mode 100644 index 00000000..1536e0b4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt @@ -0,0 +1,140 @@ +package com.bitchat.android.nostr + +import android.app.Application +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Process-owned Nostr event processing. + * + * Relay subscriptions must remain useful when no Activity exists, so their handlers cannot be + * borrowed from a ViewModel. This processor owns only application-scoped collaborators and writes + * messages to [AppStateStore], which the next UI instance hydrates from. + */ +internal class NostrBackgroundEventProcessor( + application: Application, + parentScope: CoroutineScope +) { + private val scope = CoroutineScope( + parentScope.coroutineContext + Dispatchers.IO.limitedParallelism(1) + ) + private val state = ChatState(scope) + private val dataManager = DataManager(application.applicationContext).apply { + state.setNickname(loadNickname()) + loadBlockedUsers() + loadGeohashBlockedUsers() + } + private val messageManager = MessageManager(state) + private val geohashRepository = GeohashRepository(application, state, dataManager) + private val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String): Boolean = false + override fun initiateHandshake(peerID: String) = Unit + override fun getMyPeerID(): String = "" + }, + trackUnreadMessages = false + ) + private val geohashMessageHandler = GeohashMessageHandler( + application = application, + repo = geohashRepository, + dataManager = dataManager, + addChannelMessage = AppStateStore::addChannelMessage + ) + private val directMessageHandler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = ::updateDeliveryStatus, + scope = scope, + repo = geohashRepository, + dataManager = dataManager + ) + + init { + // Keep the headless state aligned with messages sent or received through other transports. + // This preserves duplicate detection and focused-conversation behavior without retaining UI. + scope.launch { + AppStateStore.privateMessages.collect(state::setPrivateChats) + } + scope.launch { + AppStateStore.nickname.collect(state::setNickname) + } + scope.launch { + AppStateStore.selectedPrivateChatPeer.collect(state::setSelectedPrivateChatPeer) + } + } + + fun configureAccount(identity: NostrIdentity): NostrAccountEpoch { + val epoch = directMessageHandler.configureAccount(identity) + refreshBlockLists(epoch) + return epoch + } + + fun invalidateAccount() { + directMessageHandler.invalidateAccount() + geohashMessageHandler.clearAccountState() + geohashRepository.clearAll() + } + + fun onAccountDm( + event: NostrEvent, + identity: NostrIdentity, + accountEpoch: NostrAccountEpoch + ) { + if (!refreshBlockLists(accountEpoch)) return + directMessageHandler.onGiftWrap(event, "", identity, accountEpoch) + } + + fun onGeohashMessage( + event: NostrEvent, + geohash: String, + accountEpoch: NostrAccountEpoch + ) { + if (!refreshBlockLists(accountEpoch)) return + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + } + + fun onGeohashDm( + event: NostrEvent, + geohash: String, + identity: NostrIdentity, + accountEpoch: NostrAccountEpoch + ) { + if (!refreshBlockLists(accountEpoch)) return + directMessageHandler.onGiftWrap(event, geohash, identity, accountEpoch) + } + + fun conversationGeohash(conversationKey: String): String? = + geohashRepository.getConversationGeohash(conversationKey) + ?: GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String = + geohashRepository.displayNameForNostrPubkeyUI(pubkeyHex) + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = + geohashRepository.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + + private fun updateDeliveryStatus(messageId: String, status: DeliveryStatus) { + messageManager.updateMessageDeliveryStatus(messageId, status) + // The headless state may not yet contain a just-sent UI message. Update the process store + // unconditionally so a delivery/read receipt can never be lost during Activity handoff. + AppStateStore.updatePrivateMessageStatus(messageId, status) + } + + private fun refreshBlockLists(accountEpoch: NostrAccountEpoch): Boolean = + NostrInboundAccountLifecycle.runIfCurrent(accountEpoch) { + dataManager.loadBlockedUsers() + dataManager.loadGeohashBlockedUsers() + } +} 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..c9c934b8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -0,0 +1,442 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.mesh.PowerManager +import kotlinx.coroutines.CancellationException +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 subscriptions dispatch directly to a process-owned event processor. The UI hydrates from + * the process state store, so relay events remain useful without retaining a cleared ViewModel. + */ +object NostrBackgroundRuntime { + private const val TAG = "NostrBackground" + + private data class ActiveAccount(val epoch: NostrAccountEpoch) + + private data class GeohashDmSubscription( + val geohash: String, + val id: String, + val liveLocationToken: Long? + ) + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val random = SecureRandom().asKotlinRandom() + private val lock = Any() + + @Volatile private var initialized = false + @Volatile private var activeGeohash: String? = null + @Volatile private var activeGeohashLiveToken: Long? = null + @Volatile private var conversationGeohash: String? = null + private var activeGeohashSubscriptionsEnabled = false + private var activeAccount: ActiveAccount? = null + private var subscriptionRevision = 0L + private val installedGeohashDmSubscriptions = + mutableMapOf() + private val pendingGeohashDmSubscriptions = mutableSetOf() + private lateinit var application: Application + private lateinit var subscriptions: NostrSubscriptionManager + private lateinit var locationChannels: LocationChannelManager + private lateinit var eventProcessor: NostrBackgroundEventProcessor + + fun initialize(app: Application) { + synchronized(lock) { + if (initialized) return + application = app + locationChannels = LocationChannelManager.getInstance(app) + eventProcessor = NostrBackgroundEventProcessor(app, scope) + subscriptions = NostrSubscriptionManager( + app, + owner = NostrRelayManager.OWNER_BACKGROUND + ) + // Publish readiness only after every process-owned dependency is available. + initialized = true + } + + subscriptions.connect() + synchronized(lock) { + configureAccountLocked() + } + observeSelectedChannel() + startPresenceScheduler() + } + + /** + * Cancel every account-bound receive before account storage or identity is + * cleared. The relay connection remains process-owned and can be reused + * after the caller completes its account reset. + */ + fun invalidateAccount() { + synchronized(lock) { + if (!initialized) return + invalidateAccountLocked() + } + } + + /** + * Atomically replace process-owned subscriptions for the current identity. + * + * Ordered unsubscribe/subscribe operations and captured account epochs + * avoid the former delay-based CLOSE/REQ race. + */ + fun resetSubscriptions() { + synchronized(lock) { + if (!initialized) return + invalidateAccountLocked() + subscriptions.connect() + configureAccountLocked() + } + } + + fun ensureConversationDm(geohash: String) { + synchronized(lock) { + if (!initialized) return + val selectedLiveToken = activeGeohashLiveToken + val selectedChannelSubscriptionIsUsable = + activeGeohashSubscriptionsEnabled && + geohash == activeGeohash && + (selectedLiveToken == null || + LiveLocationPrivacyGate.accepts(selectedLiveToken)) + conversationGeohash = + if (selectedChannelSubscriptionIsUsable) null else geohash + rebuildGeohashDmSubscriptionsLocked() + } + } + + internal fun currentAccountEpoch(): NostrAccountEpoch? = + synchronized(lock) { activeAccount?.epoch } + + private fun configureAccountLocked() { + val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return + val epoch = eventProcessor.configureAccount(identity) + if (!NostrInboundAccountLifecycle.isCurrent(epoch)) return + activeAccount = ActiveAccount(epoch) + + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = "chat-messages", + handler = { event -> + eventProcessor.onAccountDm(event, identity, epoch) + } + ) + activeGeohash + ?.takeIf { activeGeohashSubscriptionsEnabled } + ?.let { subscribeSelectedGeohashLocked(it, activeGeohashLiveToken) } + rebuildGeohashDmSubscriptionsLocked() + } + + private fun invalidateAccountLocked() { + activeAccount = null + conversationGeohash = null + subscriptionRevision += 1 + pendingGeohashDmSubscriptions.clear() + // Invalidate first so an in-flight async derivation cannot install a + // subscription after unsubscribeAllOwned(). + eventProcessor.invalidateAccount() + subscriptions.unsubscribeAllOwned() + installedGeohashDmSubscriptions.clear() + } + + private fun observeSelectedChannel() { + scope.launch { + locationChannels.selectedChannel.collectLatest { channel -> + val locationChannel = channel as? ChannelID.Location + val next = locationChannel?.channel?.geohash + val nextToken = locationChannel?.let { + locationChannels.liveLocationTokenForSelectedChannel(it.channel) + } + synchronized(lock) { + val previous = activeGeohash + if (previous == next && activeGeohashLiveToken == nextToken) { + return@synchronized + } + + previous?.let { + subscriptions.unsubscribe("geohash-$it") + } + activeGeohash = next + activeGeohashLiveToken = nextToken + activeGeohashSubscriptionsEnabled = + locationChannel == null || + !locationChannels + .isSelectedChannelLiveDerived(locationChannel.channel) || + nextToken != null + if (conversationGeohash == next) { + conversationGeohash = null + } + next + ?.takeIf { activeGeohashSubscriptionsEnabled } + ?.let { geohash -> + subscribeSelectedGeohashLocked(geohash, nextToken) + } + rebuildGeohashDmSubscriptionsLocked() + } + } + } + } + + private fun subscribeSelectedGeohashLocked( + geohash: String, + liveLocationToken: Long? + ) { + val account = activeAccount ?: return + subscriptions.subscribeGeohashMessages( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3_600_000L, + limit = 200, + id = "geohash-$geohash", + handler = { event -> + eventProcessor.onGeohashMessage(event, geohash, account.epoch) + }, + liveLocationToken = liveLocationToken + ) + } + + private fun rebuildGeohashDmSubscriptionsLocked() { + val account = activeAccount + subscriptionRevision += 1 + val revision = subscriptionRevision + pendingGeohashDmSubscriptions.clear() + + val required = if (account == null) { + emptyMap() + } else { + requiredGeohashDmSubscriptionsLocked() + } + installedGeohashDmSubscriptions.keys + .filter { id -> installedGeohashDmSubscriptions[id] != required[id] } + .forEach { id -> + subscriptions.unsubscribe(id) + installedGeohashDmSubscriptions.remove(id) + } + if (account == null) return + + required.values.forEach { request -> + if (installedGeohashDmSubscriptions[request.id] == request) return@forEach + pendingGeohashDmSubscriptions.add(request.id) + scheduleGeohashDmSubscriptionLocked(account, request, revision) + } + } + + private fun requiredGeohashDmSubscriptionsLocked(): + Map { + val required = linkedMapOf() + val selected = activeGeohash + ?.takeIf { activeGeohashSubscriptionsEnabled } + ?.let { geohash -> + GeohashDmSubscription( + geohash = geohash, + id = "geo-dm-$geohash", + liveLocationToken = activeGeohashLiveToken + ) + } + if (selected != null) { + required[selected.id] = selected + } + + val conversation = conversationGeohash + val selectedCoversConversation = + conversation != null && + selected?.geohash == conversation && + (selected.liveLocationToken == null || + LiveLocationPrivacyGate.accepts(selected.liveLocationToken)) + if (conversation != null && !selectedCoversConversation) { + val request = GeohashDmSubscription( + geohash = conversation, + id = "geo-dm-conversation-$conversation", + liveLocationToken = null + ) + required[request.id] = request + } + return required + } + + private fun scheduleGeohashDmSubscriptionLocked( + account: ActiveAccount, + request: GeohashDmSubscription, + revision: Long + ) { + val accountContext = + NostrInboundAccountLifecycle.contextFor(account.epoch) + ?: return + accountContext.receiveScope.launch { + synchronized(lock) { + if (revision != subscriptionRevision || + activeAccount != account || + requiredGeohashDmSubscriptionsLocked()[request.id] != request + ) { + return@synchronized + } + var didSubscribe = false + val installed = + NostrInboundAccountLifecycle.runIfCurrent(account.epoch) { + val subscribe = { + val identity = NostrIdentityBridge.deriveIdentity( + request.geohash, + application + ) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = request.id, + handler = { event -> + eventProcessor.onGeohashDm( + event, + request.geohash, + identity, + account.epoch + ) + }, + liveLocationToken = request.liveLocationToken + ) + didSubscribe = true + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + if (request.liveLocationToken == null) { + subscribe() + } else { + LiveLocationPrivacyGate.runIfAllowed( + request.liveLocationToken, + subscribe + ) + } + } + pendingGeohashDmSubscriptions.remove(request.id) + if (installed && didSubscribe) { + installedGeohashDmSubscriptions[request.id] = request + } + } + } + } + + private fun startPresenceScheduler() { + val powerManager = PowerManager.getInstance(application) + scope.launch { + val nostrSchedule = powerManager.profile + .map { it.nostr } + .distinctUntilChanged() + combine( + locationChannels.availableChannels, + LiveLocationPrivacyGate.enabled, + nostrSchedule + ) { channels, liveLocationEnabled, schedule -> + val targets = GeohashNostrPrivacyPolicy.livePresenceTargets( + availableChannels = channels, + liveLocationEnabled = liveLocationEnabled + ) + 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 account = + synchronized(lock) { activeAccount } + ?: return@forEach + var token: Long? = null + var identity: NostrIdentity? = null + val prepared = + NostrInboundAccountLifecycle.runIfCurrent( + account.epoch + ) mutation@{ + val capturedToken = + LiveLocationPrivacyGate.captureToken() + ?: return@mutation + token = capturedToken + if (geohash !in + GeohashNostrPrivacyPolicy.livePresenceTargets( + availableChannels = + locationChannels.availableChannels.value, + liveLocationEnabled = true + ) + ) return@mutation + + LiveLocationPrivacyGate.runIfAllowed(capturedToken) { + identity = NostrIdentityBridge.deriveIdentity( + geohash, + application + ) + } + } + val liveToken = token + val preparedIdentity = identity + if (!prepared || + liveToken == null || + preparedIdentity == null || + !LiveLocationPrivacyGate.accepts(liveToken) + ) return@forEach + val event = NostrProtocol.createGeohashPresenceEvent( + geohash, + preparedIdentity + ) + NostrInboundAccountLifecycle.runIfCurrent(account.epoch) { + LiveLocationPrivacyGate.runIfAllowed(liveToken) { + NostrRelayManager.getInstance(application) + .sendEventToGeohash( + event = event, + geohash = geohash, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveToken + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}") + } + } + } + } + } + } + + fun conversationGeohash(conversationKey: String): String? = + if (initialized) eventProcessor.conversationGeohash(conversationKey) + else GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String? = + if (initialized) eventProcessor.displayNameForNostrPubkey(pubkeyHex) else null + + fun displayNameForGeohashConversation( + pubkeyHex: String, + sourceGeohash: String + ): String? = if (initialized) { + eventProcessor.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + } else { + null + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index b0e9d16f..788e49ca 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -16,9 +16,9 @@ import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MeshDelegateHandler import com.bitchat.android.ui.PrivateChatManager import com.bitchat.android.ui.PrivateMessageOrigin +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -30,7 +30,7 @@ class NostrDirectMessageHandler( private val application: Application, private val state: ChatState, private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, + private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit, private val scope: CoroutineScope, private val repo: GeohashRepository, private val dataManager: com.bitchat.android.ui.DataManager, @@ -46,8 +46,7 @@ class NostrDirectMessageHandler( private val seenStore by lazy(seenStoreProvider) private val ndrService by lazy { NdrNostrService.getInstance(application) } - @Volatile - private var ndrCallbackInstalled = false + private val accountLock = Any() // Simple event deduplication private val processedIds = ArrayDeque() @@ -79,63 +78,75 @@ class NostrDirectMessageHandler( * subscription handler so a delayed old subscription cannot join a newer * account merely because its event arrives later. */ - internal fun configureAccount(identity: NostrIdentity): NostrAccountEpoch { - val accountContext = NostrInboundAccountLifecycle.begin( - accountPubkeyHex = identity.publicKeyHex, - parentJob = scope.coroutineContext[Job] - ) - if (!NdrFeatureGate.isEnabled()) { - if (ndrCallbackInstalled) { - ndrService.onDecryptedMessage = null - ndrCallbackInstalled = false + internal fun configureAccount(identity: NostrIdentity): NostrAccountEpoch = + synchronized(accountLock) { + val accountContext = NostrInboundAccountLifecycle.begin( + accountPubkeyHex = identity.publicKeyHex, + parentJob = scope.coroutineContext[Job] + ) + // A prior account's callback must not consume and discard pending + // deliveries while the replacement runtime is initialized. + ndrService.onDecryptedMessage = null + val configured = ndrService.configureIfNeeded(identity) { + NostrInboundAccountLifecycle.isCurrent(accountContext.epoch) } - return accountContext.epoch - } - // A prior account's callback must not consume and discard pending - // deliveries while the replacement runtime is initialized. - ndrService.onDecryptedMessage = null - ndrService.configureIfNeeded(identity) - ndrService.onDecryptedMessage = callback@{ message, completion -> - if (!NdrFeatureGate.isEnabled() || + if (!configured || + !NdrFeatureGate.isEnabled() || !NostrInboundAccountLifecycle.isCurrent(accountContext.epoch) ) { - completion(NdrDeliveryResult.REJECTED) - return@callback + return@synchronized accountContext.epoch } - val currentIdentity = - NostrIdentityBridge.getCurrentNostrIdentity(application) - if (currentIdentity == null) { - completion(NdrDeliveryResult.RETRY) - return@callback - } - if (!currentIdentity.publicKeyHex.equals( - accountContext.epoch.accountPubkeyHex, - ignoreCase = true + + val callback = callback@{ + message: NdrDecryptedMessage, + completion: (NdrDeliveryResult) -> Unit -> + if (!NdrFeatureGate.isEnabled() || + !NostrInboundAccountLifecycle.isCurrent(accountContext.epoch) + ) { + completion(NdrDeliveryResult.REJECTED) + return@callback + } + val currentIdentity = + NostrIdentityBridge.getCurrentNostrIdentity(application) + if (currentIdentity == null) { + completion(NdrDeliveryResult.RETRY) + return@callback + } + if (!currentIdentity.publicKeyHex.equals( + accountContext.epoch.accountPubkeyHex, + ignoreCase = true + ) + ) { + completion(NdrDeliveryResult.REJECTED) + return@callback + } + onDoubleRatchetMessage( + message, + currentIdentity, + accountContext.epoch, + accountContext.receiveScope, + completion ) - ) { - completion(NdrDeliveryResult.REJECTED) - return@callback } - onDoubleRatchetMessage( - message, - currentIdentity, - accountContext.epoch, - accountContext.receiveJob, - completion - ) + ndrService.onDecryptedMessage = callback + if (!NostrInboundAccountLifecycle.isCurrent(accountContext.epoch)) { + ndrService.onDecryptedMessage = null + return@synchronized accountContext.epoch + } + accountContext.epoch } - ndrCallbackInstalled = true - return accountContext.epoch - } internal fun currentAccountEpoch(): NostrAccountEpoch? = NostrInboundAccountLifecycle.currentEpoch() fun invalidateAccount() { - NostrInboundAccountLifecycle.invalidate() - if (ndrCallbackInstalled) { + synchronized(accountLock) { + NostrInboundAccountLifecycle.invalidate() ndrService.onDecryptedMessage = null - ndrCallbackInstalled = false + synchronized(this) { + processedIds.clear() + seen.clear() + } } } @@ -148,10 +159,15 @@ class NostrDirectMessageHandler( val accountContext = NostrInboundAccountLifecycle.contextFor(accountEpoch) ?: return null - return scope.launch(Dispatchers.Default + accountContext.receiveJob) { + return accountContext.receiveScope.launch { try { if (!isAccountEpochCurrent(accountEpoch)) return@launch - if (dedupe(giftWrap.id)) return@launch + var duplicate = false + if (!runIfAccountMutationCurrent(accountEpoch, null) { + duplicate = dedupe(giftWrap.id) + } + ) return@launch + if (duplicate) return@launch val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt if (messageAge > 173700) return@launch // 48 hours + 15 mins @@ -186,6 +202,8 @@ class NostrDirectMessageHandler( accountEpoch = accountEpoch ) + } catch (e: CancellationException) { + throw e } catch (_: Exception) { Log.e(TAG, "Failed to process gift wrap") } @@ -196,10 +214,10 @@ class NostrDirectMessageHandler( message: NdrDecryptedMessage, identity: NostrIdentity, accountEpoch: NostrAccountEpoch, - receiveJob: Job, + receiveScope: CoroutineScope, completion: (NdrDeliveryResult) -> Unit ) { - scope.launch(Dispatchers.Default + receiveJob) { + receiveScope.launch { var result = NdrDeliveryResult.RETRY try { if (!NdrFeatureGate.isEnabled() || @@ -256,6 +274,8 @@ class NostrDirectMessageHandler( ndrEventId = dedupeId, expiresAtSeconds = applicationMessage.expiresAtSeconds ) + } catch (_: CancellationException) { + result = NdrDeliveryResult.REJECTED } catch (_: Exception) { Log.e(TAG, "Failed to process double-ratchet message") result = NdrDeliveryResult.RETRY @@ -470,7 +490,10 @@ class NostrDirectMessageHandler( var consumed = false withContext(Dispatchers.Main) { runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { - meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID) + updateDeliveryStatus( + messageId, + DeliveryStatus.Delivered(conversationID, Date()) + ) consumed = true } } @@ -481,7 +504,10 @@ class NostrDirectMessageHandler( var consumed = false withContext(Dispatchers.Main) { runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { - meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID) + updateDeliveryStatus( + messageId, + DeliveryStatus.Read(conversationID, Date()) + ) consumed = true } } @@ -490,60 +516,68 @@ class NostrDirectMessageHandler( NoisePayloadType.FILE_TRANSFER -> { val file = BitchatFilePacket.decode(payload.data) if (file != null) { - if (ndrEventId != null && - state.getPrivateChatsValue()[conversationID] - .orEmpty() - .any { it.id.equals(ndrEventId, ignoreCase = true) } - ) { - return NdrDeliveryResult.DUPLICATE - } + var savedPath: String? = null + var retained = false var message: BitchatMessage? = null - if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { - val savedPath = - com.bitchat.android.features.file.FileUtils.saveIncomingFile( - context = application, - file = file, - stableId = ndrEventId - ) - message = BitchatMessage( - id = ndrEventId ?: java.util.UUID.randomUUID().toString().uppercase(), - sender = senderNickname, - content = savedPath, - type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), - timestamp = timestamp, - isRelay = false, - isPrivate = true, - recipientNickname = state.getNicknameValue(), - senderPeerID = conversationID, - senderNostrPubkey = senderPubkey - ) + try { + if (ndrEventId != null && + state.getPrivateChatsValue()[conversationID] + .orEmpty() + .any { it.id.equals(ndrEventId, ignoreCase = true) } + ) { + return NdrDeliveryResult.DUPLICATE } - ) return NdrDeliveryResult.REJECTED - val savedPath = message?.content - if (isExpired(expiresAtSeconds)) { - savedPath?.let { java.io.File(it).delete() } - return NdrDeliveryResult.REJECTED - } - var consumed = false - withContext(Dispatchers.Main) { - runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { - message?.let { - privateChatManager.handleIncomingPrivateMessage( - message = it, - suppressUnread = false, - origin = PrivateMessageOrigin.NOSTR + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + val path = + com.bitchat.android.features.file.FileUtils.saveIncomingFile( + context = application, + file = file, + stableId = ndrEventId + ) + savedPath = path + message = BitchatMessage( + id = ndrEventId + ?: java.util.UUID.randomUUID().toString().uppercase(), + sender = senderNickname, + content = path, + type = + com.bitchat.android.features.file.FileUtils + .messageTypeForMime(file.mimeType), + timestamp = timestamp, + isRelay = false, + isPrivate = true, + recipientNickname = state.getNicknameValue(), + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey ) - consumed = true + } + ) return NdrDeliveryResult.REJECTED + if (isExpired(expiresAtSeconds)) { + return NdrDeliveryResult.REJECTED + } + var consumed = false + withContext(Dispatchers.Main) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + message?.let { + privateChatManager.handleIncomingPrivateMessage( + message = it, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + consumed = true + } } } - } - if (consumed) { - NdrDeliveryResult.CONSUMED - } else { - if (isExpired(expiresAtSeconds)) { + retained = consumed + if (consumed) { + NdrDeliveryResult.CONSUMED + } else { + NdrDeliveryResult.REJECTED + } + } finally { + if (!retained) { savedPath?.let { java.io.File(it).delete() } } - NdrDeliveryResult.REJECTED } } else { Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID") diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt b/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt index eef4e047..c4afada8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt @@ -1,12 +1,16 @@ package com.bitchat.android.nostr +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob internal data class NostrInboundAccountContext( val epoch: NostrAccountEpoch, val receiveJob: Job -) +) { + val receiveScope = CoroutineScope(Dispatchers.IO + receiveJob) +} /** * Process-wide lifetime for all account-bound Nostr receive work. diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt new file mode 100644 index 00000000..1e120b05 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -0,0 +1,110 @@ +package com.bitchat.android.nostr + +/** + * Thread-safe bounded queue of relay deliveries awaiting a usable WebSocket. + * + * Queue entries have a local ID rather than using the Nostr event ID: the same signed event may be + * intentionally published more than once with different relay sets or privacy provenance. + */ +internal class NostrPendingEventQueue( + private val capacity: Int +) { + init { + require(capacity > 0) + } + + data class Delivery( + val queueId: Long, + val event: NostrEvent, + val liveLocationToken: Long?, + val accountGeneration: Long + ) + + private data class Entry( + val queueId: Long, + val event: NostrEvent, + val pendingRelayUrls: MutableSet, + val liveLocationToken: Long?, + val accountGeneration: Long + ) + + private val lock = Any() + private val entries = ArrayDeque() + private var nextQueueId = 1L + + fun enqueue( + event: NostrEvent, + relayUrls: Collection, + liveLocationToken: Long?, + accountGeneration: Long + ): Long? { + val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() } + if (pendingRelays.isEmpty()) return null + + return synchronized(lock) { + if (entries.size >= capacity) entries.removeFirst() + val queueId = nextQueueId++ + entries.addLast( + Entry( + queueId = queueId, + event = event, + pendingRelayUrls = pendingRelays, + liveLocationToken = liveLocationToken, + accountGeneration = accountGeneration + ) + ) + queueId + } + } + + fun pendingForRelay( + relayUrl: String, + accountGeneration: Long + ): List = synchronized(lock) { + entries + .asSequence() + .filter { + it.accountGeneration == accountGeneration && + relayUrl in it.pendingRelayUrls + } + .map { + Delivery( + queueId = it.queueId, + event = it.event, + liveLocationToken = it.liveLocationToken, + accountGeneration = it.accountGeneration + ) + } + .toList() + } + + fun markDelivered(queueId: Long, relayUrl: String) { + synchronized(lock) { + val iterator = entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.queueId != queueId) continue + entry.pendingRelayUrls.remove(relayUrl) + if (entry.pendingRelayUrls.isEmpty()) iterator.remove() + return + } + } + } + + fun removeLiveLocationEvents(accountGeneration: Long) { + synchronized(lock) { + entries.removeAll { + it.accountGeneration == accountGeneration && + it.liveLocationToken != null + } + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + } + } + + internal fun size(): Int = synchronized(lock) { entries.size } +} 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 bc499494..62266af6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -2,17 +2,18 @@ package com.bitchat.android.nostr import android.util.Log import com.bitchat.android.geohash.LiveLocationPrivacyGate -import com.google.gson.Gson import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import com.google.gson.JsonArray +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import com.google.gson.JsonParser import kotlinx.coroutines.* import okhttp3.* import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import kotlin.math.min import kotlin.math.pow @@ -20,6 +21,10 @@ import kotlin.math.pow internal fun isNip20ConfirmedSuccess(accepted: Boolean, message: String?): Boolean = accepted || message?.startsWith("duplicate:") == true +internal class RelayAccountResetToken internal constructor( + internal val generation: Long +) + /** * Manages WebSocket connections to Nostr relays * Compatible with iOS implementation with Android-specific optimizations @@ -34,13 +39,17 @@ class NostrRelayManager internal constructor( companion object { @JvmStatic 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 } @@ -58,7 +67,7 @@ class NostrRelayManager internal constructor( private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS private const val CONFIRMED_PUBLISH_TIMEOUT_MS = 15_000L - + fun defaultRelays(): List = DEFAULT_RELAYS } @@ -87,11 +96,13 @@ class NostrRelayManager internal 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>() private val commitAwareMessageHandlers = ConcurrentHashMap Boolean>() - private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() + private val pendingGiftWrapGenerations = ConcurrentHashMap() // Persistent subscription tracking for robust reconnection private val activeSubscriptions = ConcurrentHashMap() // subscription ID -> info @@ -105,24 +116,21 @@ class NostrRelayManager internal constructor( val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays val createdAt: Long = System.currentTimeMillis(), - val liveLocationToken: Long? = null - ) - - // Message queue for reliability - private data class QueuedEvent( - val event: NostrEvent, - val targetRelays: List, + val originGeohash: String? = null, + val owner: String = OWNER_LEGACY, val liveLocationToken: Long? = null, val accountGeneration: Long ) - private val messageQueue = mutableListOf() - private val messageQueueLock = Any() + // Bounded per-relay delivery queue for reconnect reliability. + private val messageQueue = NostrPendingEventQueue(MAX_QUEUED_EVENTS) + private val accountGeneration = AtomicLong(0L) private val accountGenerationLock = Any() private val accountResetOperationLock = Any() - @Volatile - private var accountResetBlocked = false + @Volatile private var accountResetBlocked = false + private var preparedResetGeneration = Long.MIN_VALUE + private var discardedResetGeneration = Long.MIN_VALUE private data class ConfirmedPublish( val awaitingRelayUrls: MutableSet, @@ -137,19 +145,26 @@ class NostrRelayManager internal constructor( val ordinaryHandler: ((NostrEvent) -> Unit)? ) - private data class DisconnectionOutcome( - val confirmedCompletions: List>, - val reconnectDelayMs: Long?, - val connectionToken: Long? + private val confirmedPublishes = ConcurrentHashMap() + + private data class AccountResetCleanup( + val jobs: List, + val sockets: List, + val confirmedPublishes: List ) - private val confirmedPublishes = ConcurrentHashMap() - @Volatile - private var ndrConnectionAvailableHandler: (() -> Unit)? = null + private data class NdrConnectionHandler( + val accountGeneration: Long, + val callback: () -> Unit + ) + + @Volatile private var ndrConnectionAvailableHandler: NdrConnectionHandler? = null // 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 @@ -210,11 +225,7 @@ class NostrRelayManager internal constructor( } } if (registered) { - ensureConnectionsFor( - selected, - liveLocationToken, - generation - ) + ensureConnectionsFor(selected, liveLocationToken, generation) } } } catch (e: Exception) { @@ -239,6 +250,7 @@ class NostrRelayManager internal constructor( handler: (NostrEvent) -> Unit, includeDefaults: Boolean = false, nRelays: Int = 5, + owner: String = OWNER_LEGACY, liveLocationToken: Long? = null, expectedAccountGeneration: Long = accountGeneration.get() ): String { @@ -252,15 +264,19 @@ class NostrRelayManager internal constructor( liveLocationToken, generation ) - if (!isNetworkActionAllowed(liveLocationToken)) return id + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) + ) return id val relayUrls = getRelaysForGeohash(geohash) return subscribe( filter = filter, id = id, handler = handler, targetRelayUrls = relayUrls, + owner = owner, liveLocationToken = liveLocationToken, - expectedAccountGeneration = generation + expectedAccountGeneration = generation, + originGeohash = geohash ) } @@ -285,7 +301,9 @@ class NostrRelayManager internal constructor( liveLocationToken, generation ) - if (!isNetworkActionAllowed(liveLocationToken)) return + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) + ) return val relayUrls = getRelaysForGeohash(geohash) if (relayUrls.isEmpty()) { Log.w(TAG, "No target relays for geohash event; falling back to defaults") @@ -333,18 +351,36 @@ class NostrRelayManager internal constructor( ) closeTargets.forEach { (relayUrl, relaySubscriptionIds) -> val webSocket = connections[relayUrl] ?: return@forEach - relaySubscriptionIds.forEach { subscriptionId -> + for (subscriptionId in relaySubscriptionIds) { + if (!isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket + ) return val request = NostrRequest.Close(subscriptionId) val message = gson.toJson(request, NostrRequest::class.java) - var closeQueued = false - synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation)) return - closeQueued = runCatching { webSocket.send(message) } - .getOrDefault(false) - } + val closeQueued = runCatching { webSocket.send(message) } + .getOrDefault(false) if (!closeQueued) { connections.remove(relayUrl, webSocket) + subscriptions.remove(relayUrl) webSocket.cancel() + updateRelayStatus( + relayUrl, + isConnected = false, + error = IllegalStateException("Failed to close revoked subscription") + ) + if (desiredConnected.get() && + relayUrl in nonLiveRelayUrls && + isCurrentAccountGeneration(generation) + ) { + scope.launch { + connectToRelay( + relayUrl, + liveLocationToken = null, + generation = generation + ) + } + } + break } } } @@ -352,35 +388,50 @@ class NostrRelayManager internal constructor( private fun revokeLiveLocationAccess() { val generation = accountGeneration.get() - liveLocationConnectionJobs.forEach(Job::cancel) - liveLocationConnectionJobs.clear() + if (!isCurrentAccountGeneration(generation)) return val liveSubscriptionIds = activeSubscriptions.values .filter { it.liveLocationToken != null } .mapTo(mutableSetOf()) { it.id } closeSubscriptionsOnConnectedRelays(liveSubscriptionIds, generation) - liveSubscriptionIds.forEach { id -> - activeSubscriptions.remove(id) - messageHandlers.remove(id) - } - subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } + val (socketsToCancel, reconnectsToCancel, connectionJobsToCancel) = + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + liveSubscriptionIds.forEach { id -> + val subscription = activeSubscriptions[id] + if (subscription?.accountGeneration == generation) { + activeSubscriptions.remove(id, subscription) + messageHandlers.remove(id) + commitAwareMessageHandlers.remove(id) + } + } + subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } - synchronized(messageQueueLock) { - messageQueue.removeAll { it.liveLocationToken != null } - } + messageQueue.removeLiveLocationEvents(generation) - liveGeohashTokens.keys.forEach(geohashToRelays::remove) - liveGeohashTokens.clear() + liveGeohashTokens.keys.forEach(geohashToRelays::remove) + liveGeohashTokens.clear() - val liveOnlyRelayUrls = liveLocationRelayTokens.keys - .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } - liveOnlyRelayUrls.forEach { relayUrl -> - connections.remove(relayUrl)?.cancel() - } - synchronized(relaysList) { - relaysList.removeAll { it.url in liveOnlyRelayUrls } - } - liveLocationRelayTokens.clear() + val liveOnlyRelayUrls = liveLocationRelayTokens.keys + .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } + val sockets = liveOnlyRelayUrls.mapNotNull { relayUrl -> + subscriptions.remove(relayUrl) + connections.remove(relayUrl) + } + val reconnects = liveOnlyRelayUrls.mapNotNull { relayUrl -> + reconnectJobs.remove(relayUrl) + } + synchronized(relaysList) { + relaysList.removeAll { it.url in liveOnlyRelayUrls } + } + liveLocationRelayTokens.clear() + val connectionJobs = liveLocationConnectionJobs.toList() + liveLocationConnectionJobs.clear() + Triple(sockets, reconnects, connectionJobs) + } + connectionJobsToCancel.forEach(Job::cancel) + reconnectsToCancel.forEach(Job::cancel) + socketsToCancel.forEach { it.cancel() } updateRelaysList() updateConnectionStatus() } @@ -393,22 +444,35 @@ class NostrRelayManager internal constructor( val generation = expectedAccountGeneration if (!isCurrentAccountGeneration(generation)) return if (!isNetworkActionAllowed(liveLocationToken)) return - // Ensure relays are tracked for UI/status - relayUrls.forEach { url -> - if (relaysList.none { it.url == url }) { - relaysList.add(Relay(url)) + // Ensure relays are tracked for UI/status. + val tracked = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + false + } else { + synchronized(relaysList) { + relayUrls.forEach { url -> + if (relaysList.none { it.url == url }) { + relaysList.add(Relay(url)) + } + } + } + true } } + if (!tracked) return updateRelaysList() + if (!desiredConnected.get()) return val job = scope.launch { - if (!isCurrentAccountGeneration(generation) || + if (!desiredConnected.get() || + !isCurrentAccountGeneration(generation) || !isNetworkActionAllowed(liveLocationToken) ) return@launch relayUrls.forEach { relayUrl -> launch { - if (!connections.containsKey(relayUrl) && + if (desiredConnected.get() && isCurrentAccountGeneration(generation) && + !connections.containsKey(relayUrl) && isNetworkActionAllowed(liveLocationToken) ) { connectToRelay(relayUrl, liveLocationToken, generation) @@ -417,7 +481,14 @@ class NostrRelayManager internal constructor( } } if (liveLocationToken != null) { - liveLocationConnectionJobs.add(job) + val registered = synchronized(accountGenerationLock) { + isCurrentAccountGeneration(generation) && + liveLocationConnectionJobs.add(job) + } + if (!registered) { + job.cancel() + return + } job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) } } } @@ -448,17 +519,28 @@ class NostrRelayManager internal constructor( * Connect to all configured relays */ fun connect() { - val generation = accountGeneration.get() + val (generation, relayUrls) = synchronized(accountGenerationLock) { + val current = accountGeneration.get() + if (!isCurrentAccountGeneration(current)) return + desiredConnected.set(true) + current to synchronized(relaysList) { + relaysList.map { it.url } + } + } + Log.i(TAG, "Connecting to ${relayUrls.size} Nostr relays") scope.launch { - if (!isCurrentAccountGeneration(generation)) return@launch - relaysList.forEach { relay -> + if (!desiredConnected.get() || + !isCurrentAccountGeneration(generation) + ) return@launch + relayUrls.forEach { relayUrl -> launch { - val liveToken = liveLocationRelayTokens[relay.url] - ?.takeIf { relay.url !in nonLiveRelayUrls } - if (isCurrentAccountGeneration(generation) && + val liveToken = liveLocationRelayTokens[relayUrl] + ?.takeIf { relayUrl !in nonLiveRelayUrls } + if (desiredConnected.get() && + isCurrentAccountGeneration(generation) && (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) ) { - connectToRelay(relay.url, liveToken, generation) + connectToRelay(relayUrl, liveToken, generation) } } } @@ -472,28 +554,33 @@ class NostrRelayManager internal constructor( * Disconnect from all relays */ fun disconnect() { + Log.i(TAG, "Disconnecting from all Nostr relays") + desiredConnected.set(false) + // Stop subscription validation stopSubscriptionValidation() + reconnectJobs.values.forEach(Job::cancel) + reconnectJobs.clear() + liveLocationConnectionJobs.forEach(Job::cancel) + liveLocationConnectionJobs.clear() + + val sockets = connections.values.toList() + connections.clear() + sockets.forEach { webSocket -> + webSocket.close(1000, "Manual disconnect") + } confirmedPublishes.entries.toList().forEach { (eventId, tracker) -> completeConfirmedPublish(eventId, tracker, accepted = false) } - - connections.values.forEach { webSocket -> - webSocket.close(1000, "Manual disconnect") - } - connections.clear() - val disconnectedAt = System.currentTimeMillis() - relaysList.forEach { relay -> - if (relay.isConnected) { - relay.lastDisconnectedAt = disconnectedAt - } - relay.isConnected = false - } - - // 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() } @@ -508,23 +595,29 @@ class NostrRelayManager internal constructor( ): Boolean { val generation = expectedAccountGeneration if (!isCurrentAccountGeneration(generation)) return false - val targetRelays = relayUrls ?: relaysList.map { it.url } + val configuredRelays = relayUrls ?: synchronized(relaysList) { + relaysList.map { it.url } + } + val targetRelays = configuredRelays + .filter { it.isNotBlank() } + .distinct() + if (targetRelays.isEmpty()) return false - var accepted = false - val queued = runNetworkAction(liveLocationToken) { - synchronized(messageQueueLock) { - if (!isCurrentAccountGeneration(generation)) return@runNetworkAction - messageQueue.add( - QueuedEvent( + var queueId: Long? = null + val allowed = runNetworkAction(liveLocationToken) { + queueId = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + null + } else { + messageQueue.enqueue( event = event, - targetRelays = targetRelays, + relayUrls = targetRelays, liveLocationToken = liveLocationToken, accountGeneration = generation ) - ) - accepted = true + } } - if (!accepted) return@runNetworkAction + val admittedQueueId = queueId ?: return@runNetworkAction scope.launch { if (!isCurrentAccountGeneration(generation) || !isNetworkActionAllowed(liveLocationToken) @@ -532,23 +625,26 @@ class NostrRelayManager internal constructor( targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - sendToRelay( - event, - webSocket, - relayUrl, - liveLocationToken, - generation - ) + if (sendToRelay( + event = event, + webSocket = webSocket, + relayUrl = relayUrl, + liveLocationToken = liveLocationToken, + generation = generation + ) + ) { + messageQueue.markDelivered(admittedQueueId, relayUrl) + } } } } } - return queued && accepted + return allowed && queueId != null } /** - * Sends without using the process-local retry queue and completes only - * after at least one relay returns an accepted NIP-01 OK. + * Publish without the retry queue and complete after one relay confirms the + * event, or after every connected target rejects/disconnects. */ fun sendEventConfirmed( event: NostrEvent, @@ -560,7 +656,13 @@ class NostrRelayManager internal constructor( completion(false) return } - val requestedRelays = (relayUrls ?: relaysList.map { it.url }).toSet() + + val configuredRelays = relayUrls ?: synchronized(relaysList) { + relaysList.map { it.url } + } + val requestedRelays = configuredRelays + .filter { it.isNotBlank() } + .toSet() val connectedTargets = requestedRelays.filterTo(linkedSetOf()) { connections.containsKey(it) } @@ -576,16 +678,17 @@ class NostrRelayManager internal constructor( completion = completion, accountGeneration = generation ) - if (confirmedPublishes.putIfAbsent(event.id, tracker) != null) { + val registered = synchronized(accountGenerationLock) { + isCurrentAccountGeneration(generation) && + confirmedPublishes.putIfAbsent(event.id, tracker) == null + } + if (!registered) { completion(false) return } + tracker.timeoutJob = scope.launch { delay(CONFIRMED_PUBLISH_TIMEOUT_MS) - if (!isCurrentAccountGeneration(generation)) { - completeConfirmedPublish(event.id, tracker, accepted = false) - return@launch - } completeConfirmedPublish(event.id, tracker, accepted = false) } @@ -593,10 +696,10 @@ class NostrRelayManager internal constructor( val webSocket = connections[relayUrl] if (webSocket == null || !sendToRelay( - event, - webSocket, - relayUrl, - accountGeneration = generation + event = event, + webSocket = webSocket, + relayUrl = relayUrl, + generation = generation ) ) { tracker.awaitingRelayUrls.remove(relayUrl) @@ -604,7 +707,6 @@ class NostrRelayManager internal constructor( } if (tracker.awaitingRelayUrls.isEmpty()) { completeConfirmedPublish(event.id, tracker, accepted = false) - return } } @@ -616,7 +718,13 @@ class NostrRelayManager internal constructor( } fun setNdrConnectionAvailableHandler(handler: () -> Unit) { - ndrConnectionAvailableHandler = handler + val generation = accountGeneration.get() + synchronized(accountGenerationLock) { + if (isCurrentAccountGeneration(generation)) { + ndrConnectionAvailableHandler = + NdrConnectionHandler(generation, handler) + } + } } private fun completeConfirmedPublish( @@ -641,28 +749,33 @@ class NostrRelayManager internal constructor( id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, targetRelayUrls: List? = null, + owner: String = OWNER_LEGACY, liveLocationToken: Long? = null, - expectedAccountGeneration: Long = accountGeneration.get() + expectedAccountGeneration: Long = accountGeneration.get(), + originGeohash: String? = null ): String { + val generation = expectedAccountGeneration val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, handler = handler, targetRelayUrls = targetRelayUrls?.toSet(), - liveLocationToken = liveLocationToken + originGeohash = originGeohash, + owner = owner, + liveLocationToken = liveLocationToken, + accountGeneration = generation ) registerSubscription( subscriptionInfo = subscriptionInfo, ordinaryHandler = handler, - expectedAccountGeneration = expectedAccountGeneration + expectedAccountGeneration = generation ) return id } /** - * NDR relay copies are considered seen only after the durable runtime - * commits them. A transient storage failure must leave another relay copy - * eligible for processing. + * NDR relay copies become deduplicated only after durable processing. A + * rejected registration is reported so the native action remains pending. */ fun subscribeAfterSuccessfulProcessing( filter: NostrFilter, @@ -675,7 +788,8 @@ class NostrRelayManager internal constructor( id = id, filter = filter, handler = {}, - targetRelayUrls = targetRelayUrls?.toSet() + targetRelayUrls = targetRelayUrls?.toSet(), + accountGeneration = expectedAccountGeneration ) return registerSubscription( subscriptionInfo = subscriptionInfo, @@ -685,9 +799,8 @@ class NostrRelayManager internal constructor( } /** - * Installs the complete handler mode before any relay can observe the REQ. - * Some WebSocket implementations can synchronously deliver a cached EVENT - * from inside send(), so handler replacement after send is already too late. + * Install the complete handler mode before any REQ can synchronously deliver + * a cached event from a test or WebSocket implementation. */ private fun registerSubscription( subscriptionInfo: SubscriptionInfo, @@ -703,10 +816,12 @@ class NostrRelayManager internal constructor( if (!isCurrentAccountGeneration(generation)) return@synchronized activeSubscriptions[subscriptionInfo.id] = subscriptionInfo if (commitAwareHandler != null) { - commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler + commitAwareMessageHandlers[subscriptionInfo.id] = + commitAwareHandler messageHandlers.remove(subscriptionInfo.id) } else { - messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler) + messageHandlers[subscriptionInfo.id] = + requireNotNull(ordinaryHandler) commitAwareMessageHandlers.remove(subscriptionInfo.id) } registered = true @@ -726,6 +841,7 @@ class NostrRelayManager internal constructor( generation: Long ) { if (!isCurrentAccountGeneration(generation) || + subscriptionInfo.accountGeneration != generation || !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) ) return val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) @@ -738,20 +854,21 @@ class NostrRelayManager internal constructor( val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList() targetRelays.forEach { relayUrl -> + if (!isCurrentAccountGeneration(generation)) return@launch val webSocket = connections[relayUrl] if (webSocket != null) { try { var success = false runNetworkAction(subscriptionInfo.liveLocationToken) { - synchronized(accountGenerationLock) { - if (isCurrentAccountGeneration(generation)) { - success = webSocket.send(message) - if (success) { - val currentSubs = subscriptions[relayUrl] ?: emptySet() - subscriptions[relayUrl] = - currentSubs + subscriptionInfo.id - } - } + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + success = webSocket.send(message) + } + if (success && isCurrentAccountGeneration(generation)) { + val currentSubs = subscriptions[relayUrl] ?: emptySet() + subscriptions[relayUrl] = + currentSubs + subscriptionInfo.id } } if (!success) { @@ -772,17 +889,23 @@ class NostrRelayManager internal constructor( /** * Unsubscribe from a subscription */ - fun unsubscribe(id: String) { - val generation = accountGeneration.get() + fun unsubscribe( + id: String, + expectedAccountGeneration: Long = accountGeneration.get() + ) { + val generation = expectedAccountGeneration // Remove from persistent tracking val subscriptionInfo = synchronized(accountGenerationLock) { if (!isCurrentAccountGeneration(generation)) { null } else { - activeSubscriptions.remove(id).also { - messageHandlers.remove(id) - commitAwareMessageHandlers.remove(id) - } + activeSubscriptions[id] + ?.takeIf { it.accountGeneration == generation } + ?.also { + activeSubscriptions.remove(id, it) + messageHandlers.remove(id) + commitAwareMessageHandlers.remove(id) + } } } @@ -809,14 +932,15 @@ class NostrRelayManager internal constructor( return@launch } connections.forEach { (relayUrl, webSocket) -> + if (!isCurrentAccountGeneration(generation)) return@launch val currentSubs = subscriptions[relayUrl] if (currentSubs?.contains(id) == true) { try { runNetworkAction(subscriptionInfo.liveLocationToken) { - synchronized(accountGenerationLock) { - if (isCurrentAccountGeneration(generation)) { - webSocket.send(message) - } + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + webSocket.send(message) } } if (isCurrentAccountGeneration(generation)) { @@ -829,6 +953,20 @@ class NostrRelayManager internal constructor( } } } + + fun unsubscribeOwner( + owner: String, + expectedAccountGeneration: Long = accountGeneration.get() + ) { + if (!isCurrentAccountGeneration(expectedAccountGeneration)) return + activeSubscriptions.values + .filter { + it.owner == owner && + it.accountGeneration == expectedAccountGeneration + } + .map { it.id } + .forEach { unsubscribe(it, expectedAccountGeneration) } + } /** * Manually retry connection to a specific relay @@ -837,6 +975,10 @@ class NostrRelayManager internal constructor( val generation = accountGeneration.get() if (!isCurrentAccountGeneration(generation)) return val relay = relaysList.find { it.url == relayUrl } ?: return + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + desiredConnected.set(true) + } val liveToken = liveLocationRelayTokens[relayUrl] ?.takeIf { relayUrl !in nonLiveRelayUrls } if (!isNetworkActionAllowed(liveToken)) return @@ -846,12 +988,14 @@ class NostrRelayManager internal constructor( 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 { - if (isCurrentAccountGeneration(generation)) { + if (desiredConnected.get() && + isCurrentAccountGeneration(generation) + ) { connectToRelay(relayUrl, liveToken, generation) } } @@ -862,6 +1006,9 @@ class NostrRelayManager internal constructor( * This will automatically restore all subscriptions when reconnected */ fun resetAllConnections() { + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) return + val shouldReconnect = desiredConnected.get() disconnect() // Reset all relay states @@ -871,8 +1018,8 @@ class NostrRelayManager internal 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 && isCurrentAccountGeneration(generation)) connect() } /** @@ -881,11 +1028,18 @@ class NostrRelayManager internal constructor( */ fun reestablishAllSubscriptions() { val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) return scope.launch { if (!isCurrentAccountGeneration(generation)) return@launch connections.forEach { (relayUrl, webSocket) -> - if (isCurrentAccountGeneration(generation)) { - restoreSubscriptionsForRelay(relayUrl, webSocket, generation) + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + restoreSubscriptionsForRelay( + relayUrl, + webSocket, + generation + ) } } } @@ -907,9 +1061,7 @@ class NostrRelayManager internal constructor( geohashToRelays.clear() // Clear any queued messages waiting to be sent - synchronized(messageQueueLock) { - messageQueue.clear() - } + messageQueue.clear() Log.i(TAG, "Cleared all Nostr subscriptions and routing caches") } catch (e: Exception) { @@ -918,72 +1070,151 @@ class NostrRelayManager internal constructor( } /** - * Discard every process-local relay artifact that belongs to the old - * account. Unlike a normal disconnect, panic/quit must never replay queued - * kind-1059 gift wraps after identity replacement. + * Refuse fresh work, then discard every process-local relay artifact owned + * by the old account. The opaque token prevents one reset from reopening a + * newer reset. */ - fun discardForAccountReset(): Long { + internal fun discardForAccountReset(): RelayAccountResetToken { val resetToken = beginAccountReset() discardForAccountReset(resetToken) return resetToken } - /** - * Clear old-account state only while this caller still owns the reset. - * Reset operations are serialized so a stale reset cannot clear state after - * a newer reset has already reopened the replacement account. - */ - fun discardForAccountReset(resetToken: Long): Boolean = - synchronized(accountResetOperationLock) resetOperation@{ - synchronized(accountGenerationLock) { - if (!accountResetBlocked || - accountGeneration.get() != resetToken - ) return@resetOperation false - } + internal fun discardForAccountReset( + resetToken: RelayAccountResetToken + ): Boolean { + val cleanup = prepareAccountResetCleanup(resetToken) ?: return false - // Invalidate every already-launched send/connect/listener/reconnect job - // before clearing state or closing sockets. Account-scoped handler - // jobs use their own cancel-and-mutation barrier. - synchronized(messageQueueLock) { - messageQueue.clear() + cleanup.jobs.forEach(Job::cancel) + cleanup.sockets.forEach { socket -> + runCatching { socket.close(1000, "Account reset") } + .onFailure { socket.cancel() } + } + cleanup.confirmedPublishes.forEach { tracker -> + tracker.timeoutJob?.cancel() + runCatching { tracker.completion(false) } + .onFailure { + Log.w(TAG, "Confirmed publish reset callback failed") + } + } + + return synchronized(accountResetOperationLock) { + synchronized(accountGenerationLock) { + val generation = resetToken.generation + if (!accountResetBlocked || + accountGeneration.get() != generation || + preparedResetGeneration != generation + ) { + false + } else { + discardedResetGeneration = generation + true + } } - liveLocationConnectionJobs.forEach(Job::cancel) + } + } + + private fun prepareAccountResetCleanup( + resetToken: RelayAccountResetToken + ): AccountResetCleanup? = synchronized(accountResetOperationLock) { + synchronized(accountGenerationLock) generationCheck@{ + val generation = resetToken.generation + if (!accountResetBlocked || + accountGeneration.get() != generation + ) return@generationCheck null + + desiredConnected.set(false) + val jobs = buildList { + subscriptionValidationJob?.let(::add) + addAll(reconnectJobs.values) + addAll(liveLocationConnectionJobs) + } + subscriptionValidationJob = null + reconnectJobs.clear() liveLocationConnectionJobs.clear() - pendingGiftWrapIDs.clear() - disconnect() - clearAllSubscriptions() + + val sockets = connections.values.toList() + connections.clear() + val pendingConfirmations = + confirmedPublishes.values.toList() + confirmedPublishes.clear() + + activeSubscriptions.clear() + messageHandlers.clear() + commitAwareMessageHandlers.clear() + subscriptions.clear() + messageQueue.clear() + pendingGiftWrapGenerations.clear() + ndrConnectionAvailableHandler = null + eventDeduplicator.clear() + + geohashToRelays.clear() liveGeohashTokens.clear() liveLocationRelayTokens.clear() nonLiveRelayUrls.clear() - true - } + nonLiveRelayUrls.addAll(DEFAULT_RELAYS) + synchronized(relaysList) { + relaysList.removeAll { it.url !in DEFAULT_RELAYS } + DEFAULT_RELAYS.forEach { url -> + val relay = relaysList.find { it.url == url } + if (relay == null) { + relaysList.add(Relay(url)) + } else { + relay.isConnected = false + relay.lastError = null + relay.nextReconnectTime = null + relay.reconnectAttempts = 0 + } + } + } + preparedResetGeneration = generation + discardedResetGeneration = Long.MIN_VALUE + updateRelaysList() + updateConnectionStatus() - /** Refuse new account work and invalidate all previously captured generations. */ - fun beginAccountReset(): Long = + AccountResetCleanup( + jobs = jobs, + sockets = sockets, + confirmedPublishes = pendingConfirmations + ) + } + } + + internal fun beginAccountReset(): RelayAccountResetToken = synchronized(accountGenerationLock) { accountResetBlocked = true - accountGeneration.incrementAndGet() + preparedResetGeneration = Long.MIN_VALUE + discardedResetGeneration = Long.MIN_VALUE + RelayAccountResetToken(accountGeneration.incrementAndGet()) } - /** Permit fresh relay work only when this caller owns the latest reset. */ - fun completeAccountReset(resetToken: Long): Boolean = - synchronized(accountResetOperationLock) resetOperation@{ + internal fun completeAccountReset( + resetToken: RelayAccountResetToken + ): Boolean = + synchronized(accountResetOperationLock) { synchronized(accountGenerationLock) { + val generation = resetToken.generation if (!accountResetBlocked || - accountGeneration.get() != resetToken - ) return@resetOperation false - // Tokens captured while the reset was blocked must never become - // valid work for the replacement account. - accountGeneration.incrementAndGet() - accountResetBlocked = false - true + accountGeneration.get() != generation || + discardedResetGeneration != generation + ) { + false + } else { + // Work captured while admission was blocked must not become + // valid when the replacement account is opened. + accountGeneration.incrementAndGet() + accountResetBlocked = false + preparedResetGeneration = Long.MIN_VALUE + discardedResetGeneration = Long.MIN_VALUE + true + } } } - internal fun queuedEventCountForTesting(): Int = - synchronized(messageQueueLock) { messageQueue.size } + internal fun queuedEventCountForTesting(): Int = messageQueue.size() - internal fun pendingGiftWrapCountForTesting(): Int = pendingGiftWrapIDs.size + internal fun pendingGiftWrapCountForTesting(): Int = + pendingGiftWrapGenerations.size internal fun accountGenerationForTesting(): Long = accountGeneration.get() @@ -996,7 +1227,7 @@ class NostrRelayManager internal constructor( if (!isCurrentAccountGeneration(expectedAccountGeneration)) { false } else { - pendingGiftWrapIDs.add(id) + pendingGiftWrapGenerations[id] = expectedAccountGeneration true } } @@ -1006,15 +1237,6 @@ class NostrRelayManager internal constructor( private fun isCurrentAccountGeneration(generation: Long): Boolean = !accountResetBlocked && accountGeneration.get() == generation - - private inline fun withAccountCallback( - generation: Long, - rejected: T, - callback: () -> T - ): T { - if (!isCurrentAccountGeneration(generation)) return rejected - return callback() - } /** * Get detailed status for all relays @@ -1097,46 +1319,77 @@ class NostrRelayManager internal constructor( * Start periodic subscription validation to ensure robustness */ private fun startSubscriptionValidation(generation: Long) { - synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation)) return - stopSubscriptionValidation() // Stop any existing validation + if (!isCurrentAccountGeneration(generation)) return + stopSubscriptionValidation() // Stop any existing validation - subscriptionValidationJob = scope.launch { - while (isActive) { - delay(SUBSCRIPTION_VALIDATION_INTERVAL) - if (!isCurrentAccountGeneration(generation)) return@launch - - try { - val report = validateSubscriptionConsistency() - if (!report.isConsistent && report.connectedRelayCount > 0) { - Log.w(TAG, "Nostr subscription inconsistencies detected") - - // Auto-repair: re-establish subscriptions for relays with missing ones - connections.forEach { (relayUrl, webSocket) -> - val currentSubs = subscriptions[relayUrl] ?: emptySet() - val expectedSubs = activeSubscriptions.keys.filter { subId -> - val subInfo = activeSubscriptions[subId] - subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl) - }.toSet() - - val missingSubs = expectedSubs - currentSubs - if (missingSubs.isNotEmpty()) { - Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions") - restoreSubscriptionsForRelay( - relayUrl, - webSocket, - generation - ) - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error during subscription validation: ${e.message}") + subscriptionValidationJob = scope.launch { + if (!isCurrentAccountGeneration(generation)) return@launch + val manager = powerManager + if (manager == null) { + runSubscriptionValidationLoop( + intervalMs = com.bitchat.android.util.AppConstants.Nostr + .SUBSCRIPTION_VALIDATION_INTERVAL_MS, + generation = generation + ) + return@launch + } + + manager.profile + .map { it.nostr.subscriptionValidationMs } + .distinctUntilChanged() + .collectLatest { intervalMs -> + runSubscriptionValidationLoop(intervalMs, generation) + } + } + } + + private suspend fun runSubscriptionValidationLoop( + intervalMs: Long, + generation: Long + ) { + while (currentCoroutineContext().isActive && + desiredConnected.get() && + isCurrentAccountGeneration(generation) + ) { + delay(intervalMs) + if (!desiredConnected.get() || + !isCurrentAccountGeneration(generation) + ) break + validateAndRepairSubscriptions(generation) + } + } + + private fun validateAndRepairSubscriptions(generation: Long) { + if (!isCurrentAccountGeneration(generation)) return + try { + val report = validateSubscriptionConsistency() + if (report.isConsistent || report.connectedRelayCount == 0) return + + Log.w(TAG, "Nostr subscription inconsistencies detected") + connections.forEach { (relayUrl, webSocket) -> + if (!isCurrentAccountGeneration(generation)) return + val currentSubs = subscriptions[relayUrl] ?: emptySet() + val expectedSubs = activeSubscriptions.keys.filter { subId -> + val subInfo = activeSubscriptions[subId] + subInfo?.accountGeneration == generation && + (subInfo.targetRelayUrls == null || + subInfo.targetRelayUrls.contains(relayUrl)) + }.toSet() + + if ((expectedSubs - currentSubs).isNotEmpty() && + connections[relayUrl] === webSocket + ) { + Log.i(TAG, "Auto-repairing missing subscriptions") + restoreSubscriptionsForRelay( + relayUrl, + webSocket, + generation + ) } } + } catch (e: Exception) { + Log.e(TAG, "Error during subscription validation: ${e.message}") } - } - } /** @@ -1156,7 +1409,8 @@ class NostrRelayManager internal constructor( ) { val connectionToken = liveLocationToken ?.takeIf { urlString !in nonLiveRelayUrls } - if (!isCurrentAccountGeneration(generation) || + if (!desiredConnected.get() || + !isCurrentAccountGeneration(generation) || !isNetworkActionAllowed(connectionToken) ) return // Skip if we already have a connection @@ -1169,26 +1423,36 @@ class NostrRelayManager internal constructor( .url(urlString) .build() - runNetworkAction(connectionToken) { - synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation)) { - return@synchronized - } - val webSocket = httpClient.newWebSocket( - request, - RelayWebSocketListener(urlString, connectionToken, generation) + val started = runNetworkAction(connectionToken) { + val webSocket = httpClient.newWebSocket( + request, + RelayWebSocketListener( + relayUrl = urlString, + liveLocationToken = connectionToken, + generation = generation ) - if (isCurrentAccountGeneration(generation)) { - connections[urlString] = webSocket - } else { - webSocket.cancel() + ) + val existing = connections.putIfAbsent(urlString, webSocket) + when { + existing != null -> webSocket.close(1000, "Duplicate connection") + !desiredConnected.get() || + !isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(connectionToken) -> { + connections.remove(urlString, webSocket) + webSocket.close(1000, "Connection no longer desired") } } } + if (!started) return } catch (e: Exception) { Log.e(TAG, "Failed to create WebSocket connection") - handleDisconnection(urlString, e, liveLocationToken, generation) + handleConnectionCreationFailure( + relayUrl = urlString, + error = e, + liveLocationToken = connectionToken, + generation = generation + ) } } @@ -1197,10 +1461,11 @@ class NostrRelayManager internal constructor( webSocket: WebSocket, relayUrl: String, liveLocationToken: Long? = null, - accountGeneration: Long = this.accountGeneration.get() + generation: Long = accountGeneration.get() ): Boolean { - if (!isCurrentAccountGeneration(accountGeneration) || - !isNetworkActionAllowed(liveLocationToken) + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) || + connections[relayUrl] !== webSocket ) return false return try { val request = NostrRequest.Event(event) @@ -1208,28 +1473,32 @@ class NostrRelayManager internal constructor( var success = false runNetworkAction(liveLocationToken) { - synchronized(accountGenerationLock) { - if (isCurrentAccountGeneration(accountGeneration)) { - success = webSocket.send(message) - } + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + success = webSocket.send(message) } } - if (success) { + if (success && + isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.let { it.messagesSent += 1 } + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesSent += 1 + } updateRelaysList() true } else { - Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed") + Log.e(TAG, "Failed to send event: WebSocket send failed") false } } catch (e: Exception) { - Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}") + Log.e(TAG, "Failed to send event") false } } - + private fun handleMessage( message: String, relayUrl: String, @@ -1247,65 +1516,67 @@ class NostrRelayManager internal constructor( when (response) { is NostrResponse.Event -> { - val dispatch = synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation)) return - val relay = relaysList.find { it.url == relayUrl } - relay?.let { it.messagesReceived += 1 } - updateRelaysList() - val subscriptionInfo = - activeSubscriptions[response.subscriptionId] - ?: return - EventDispatch( - subscriptionInfo = subscriptionInfo, - commitAwareHandler = - commitAwareMessageHandlers[response.subscriptionId], - ordinaryHandler = - messageHandlers[response.subscriptionId] - ) + // Update relay stats + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesReceived += 1 } - val subscriptionInfo = dispatch.subscriptionInfo - if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return - val matches = try { - subscriptionInfo.filter.matches(response.event) - } catch (_: Exception) { - true - } - if (!matches) { - // Do not deduplicate an event that another subscription may accept. - return + updateRelaysList() + + // CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter + val subscriptionInfo = activeSubscriptions[response.subscriptionId] + ?: return + if (subscriptionInfo.accountGeneration != generation || + !isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) + ) return + subscriptionInfo.let { subInfo -> + val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true } + if (!matches) { + // Do NOT call deduplicator here to allow the correct subscription to process it later + return + } } + val dispatch = EventDispatch( + subscriptionInfo = subscriptionInfo, + commitAwareHandler = + commitAwareMessageHandlers[response.subscriptionId], + ordinaryHandler = + messageHandlers[response.subscriptionId] + ) val commitAwareHandler = dispatch.commitAwareHandler if (commitAwareHandler != null) { scope.launch { - withAccountCallback(generation, false) { - eventDeduplicator.processEventAfterSuccess(response.event) { event -> - isCurrentAccountGeneration(generation) && - commitAwareHandler(event) - } + if (!isCurrentAccountGeneration(generation)) { + return@launch + } + eventDeduplicator.processEventAfterSuccess( + response.event + ) { event -> + isCurrentAccountGeneration(generation) && + commitAwareHandler(event) } } return } val handler = dispatch.ordinaryHandler - if (handler != null) { - withAccountCallback(generation, Unit) { - eventDeduplicator.processEvent(response.event) { event -> - scope.launch(Dispatchers.Main) { - withAccountCallback(generation, Unit) { - if (isNetworkActionAllowed( - subscriptionInfo.liveLocationToken - )) { - handler(event) - } - } - } + if (handler == null) { + Log.w(TAG, "⚠️ No handler for Nostr subscription") + return + } + eventDeduplicator.processEvent(response.event) { event -> + scope.launch(Dispatchers.Main) { + if (isCurrentAccountGeneration(generation) && + isNetworkActionAllowed( + subscriptionInfo.liveLocationToken + ) + ) { + handler(event) } } - } else { - Log.w(TAG, "⚠️ No handler for Nostr subscription") } + } is NostrResponse.EndOfStoredEvents -> { @@ -1313,33 +1584,37 @@ class NostrRelayManager internal constructor( } is NostrResponse.Ok -> { - var completion: Pair? = null - val wasGiftWrap = synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation)) return - val pending = pendingGiftWrapIDs.remove(response.eventId) - confirmedPublishes[response.eventId]?.let { tracker -> - if (isNip20ConfirmedSuccess( - response.accepted, - response.message + val wasGiftWrap = pendingGiftWrapGenerations.remove( + response.eventId, + generation + ) + val tracker = confirmedPublishes[response.eventId] + ?.takeIf { it.accountGeneration == generation } + if (tracker != null) { + when { + isNip20ConfirmedSuccess( + response.accepted, + response.message + ) -> completeConfirmedPublish( + response.eventId, + tracker, + accepted = true + ) + + tracker.awaitingRelayUrls.remove(relayUrl) && + tracker.awaitingRelayUrls.isEmpty() -> + completeConfirmedPublish( + response.eventId, + tracker, + accepted = false ) - ) { - completion = tracker to true - } else if (tracker.awaitingRelayUrls.remove(relayUrl) && - tracker.awaitingRelayUrls.isEmpty() - ) { - completion = tracker to false - } } - pending } - completion?.let { (tracker, accepted) -> - completeConfirmedPublish( - response.eventId, - tracker, - accepted + if (!isNip20ConfirmedSuccess( + response.accepted, + response.message ) - } - if (!isNip20ConfirmedSuccess(response.accepted, response.message)) { + ) { val level = if (wasGiftWrap) Log.WARN else Log.ERROR Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}") } @@ -1360,88 +1635,120 @@ class NostrRelayManager internal constructor( private fun handleDisconnection( relayUrl: String, + webSocket: WebSocket, error: Throwable, liveLocationToken: Long? = null, - generation: Long = accountGeneration.get(), - webSocket: WebSocket? = null + generation: Long ) { - val outcome = synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation)) return - if (webSocket != null && !connections.remove(relayUrl, webSocket)) { - return - } - val connectionToken = liveLocationToken - ?.takeIf { relayUrl !in nonLiveRelayUrls } - val confirmedCompletions = confirmedPublishes.entries - .toList() - .mapNotNull { (eventId, tracker) -> - if (tracker.awaitingRelayUrls.remove(relayUrl) && - tracker.awaitingRelayUrls.isEmpty() - ) { - eventId to tracker - } else { - null - } - } + if (!isCurrentAccountGeneration(generation)) { + connections.remove(relayUrl, webSocket) + return + } + // Ignore callbacks from intentionally closed or replaced sockets. They must not remove a + // newer socket or schedule a reconnect after a controlled disconnect/privacy revocation. + if (!connections.remove(relayUrl, webSocket)) return + subscriptions.remove(relayUrl) + handleCurrentDisconnection( + relayUrl, + error, + liveLocationToken, + generation + ) + } - // Keep subscriptions for restoration on reconnection. - updateRelayStatus(relayUrl, false, error) + private fun handleConnectionCreationFailure( + relayUrl: String, + error: Throwable, + liveLocationToken: Long?, + generation: Long + ) { + if (!desiredConnected.get() || + !isCurrentAccountGeneration(generation) || + connections.containsKey(relayUrl) + ) return + handleCurrentDisconnection( + relayUrl, + error, + liveLocationToken, + generation + ) + } - var reconnectDelayMs: Long? = null - if (isNetworkActionAllowed(connectionToken)) { - val errorMessage = error.message?.lowercase() ?: "" - val dnsFailure = - errorMessage.contains("hostname could not be found") || - errorMessage.contains("dns") || - errorMessage.contains("unable to resolve host") - if (dnsFailure) { - Log.w(TAG, "Nostr relay DNS failure; not retrying") - } else { - val relay = relaysList.find { it.url == relayUrl } - if (relay != null) { - relay.reconnectAttempts++ - if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - Log.w(TAG, "Max Nostr relay reconnection attempts reached") - } else { - val delayMs = min( - INITIAL_BACKOFF_INTERVAL * - BACKOFF_MULTIPLIER.pow( - relay.reconnectAttempts - 1.0 - ), - MAX_BACKOFF_INTERVAL.toDouble() - ).toLong() - reconnectDelayMs = delayMs - relay.nextReconnectTime = - System.currentTimeMillis() + delayMs - Log.d(TAG, "Scheduling Nostr relay reconnection") - } - } - } + private fun handleCurrentDisconnection( + relayUrl: String, + error: Throwable, + liveLocationToken: Long?, + generation: Long + ) { + if (!isCurrentAccountGeneration(generation)) return + val connectionToken = liveLocationToken + ?.takeIf { relayUrl !in nonLiveRelayUrls } + + updateRelayStatus(relayUrl, false, error) + confirmedPublishes.entries.toList().forEach { (eventId, tracker) -> + if (tracker.accountGeneration == generation && + tracker.awaitingRelayUrls.remove(relayUrl) && + tracker.awaitingRelayUrls.isEmpty() + ) { + completeConfirmedPublish( + eventId, + tracker, + accepted = false + ) } - DisconnectionOutcome( - confirmedCompletions = confirmedCompletions, - reconnectDelayMs = reconnectDelayMs, - connectionToken = connectionToken - ) + } + if (!desiredConnected.get() || + !isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(connectionToken) + ) return + + // Check if this is a DNS error + val errorMessage = error.message?.lowercase() ?: "" + if (errorMessage.contains("hostname could not be found") || + errorMessage.contains("dns") || + errorMessage.contains("unable to resolve host")) { + + val relay = relaysList.find { it.url == relayUrl } + if (relay?.lastError == null) { + Log.w(TAG, "Nostr relay DNS failure; not retrying") + } + return } - outcome.confirmedCompletions.forEach { (eventId, tracker) -> - completeConfirmedPublish(eventId, tracker, accepted = false) + // Implement exponential backoff for non-DNS errors + val relay = relaysList.find { it.url == relayUrl } ?: return + relay.reconnectAttempts++ + + // Stop attempting after max attempts + if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + Log.w(TAG, "Max Nostr relay reconnection attempts reached") + return } - outcome.reconnectDelayMs?.let { delayMs -> - scope.launch { - delay(delayMs) - if (isCurrentAccountGeneration(generation) && - isNetworkActionAllowed(outcome.connectionToken) - ) { - connectToRelay( - relayUrl, - outcome.connectionToken, - generation - ) - } + + // Calculate backoff interval + val backoffInterval = min( + INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0), + MAX_BACKOFF_INTERVAL.toDouble() + ).toLong() + + relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval + + Log.d(TAG, "Scheduling Nostr relay reconnection") + + reconnectJobs.remove(relayUrl)?.cancel() + val reconnectJob = scope.launch { + delay(backoffInterval) + if (desiredConnected.get() && + isCurrentAccountGeneration(generation) && + isNetworkActionAllowed(connectionToken) + ) { + connectToRelay(relayUrl, connectionToken, generation) } } + reconnectJobs[relayUrl] = reconnectJob + reconnectJob.invokeOnCompletion { + reconnectJobs.remove(relayUrl, reconnectJob) + } } private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) { @@ -1483,10 +1790,12 @@ class NostrRelayManager internal constructor( webSocket: WebSocket, generation: Long ) { - if (!isCurrentAccountGeneration(generation)) return + if (!isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket + ) return val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo -> // Include subscription if it targets all relays or specifically targets this relay - isCurrentAccountGeneration(generation) && + subscriptionInfo.accountGeneration == generation && isNetworkActionAllowed(subscriptionInfo.liveLocationToken) && (subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)) @@ -1497,21 +1806,24 @@ class NostrRelayManager internal constructor( } subscriptionsToRestore.forEach { subscriptionInfo -> + if (!isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket + ) return try { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) var success = false runNetworkAction(subscriptionInfo.liveLocationToken) { - synchronized(accountGenerationLock) { - if (isCurrentAccountGeneration(generation)) { - success = webSocket.send(message) - if (success) { - val currentSubs = subscriptions[relayUrl] ?: emptySet() - subscriptions[relayUrl] = - currentSubs + subscriptionInfo.id - } - } + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + success = webSocket.send(message) + } + if (success && isCurrentAccountGeneration(generation)) { + val currentSubs = subscriptions[relayUrl] ?: emptySet() + subscriptions[relayUrl] = + currentSubs + subscriptionInfo.id } } if (!success) { @@ -1533,51 +1845,58 @@ class NostrRelayManager internal constructor( ) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation) || - !isNetworkActionAllowed(liveLocationToken) || - connections[relayUrl] !== webSocket - ) { - connections.remove(relayUrl, webSocket) - webSocket.cancel() - return - } - updateRelayStatus(relayUrl, true) + if (!desiredConnected.get() || + !isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket || + !isNetworkActionAllowed(liveLocationToken) + ) { + connections.remove(relayUrl, webSocket) + webSocket.close(1000, "Stale connection") + return } - val callbackAccepted = withAccountCallback(generation, false) { - runCatching { ndrConnectionAvailableHandler?.invoke() } - .onFailure { Log.w(TAG, "NDR reconnect callback failed") } - true - } - if (!callbackAccepted) return - synchronized(accountGenerationLock) { - if (!isCurrentAccountGeneration(generation)) return - restoreSubscriptionsForRelay(relayUrl, webSocket, generation) + reconnectJobs.remove(relayUrl)?.cancel() + updateRelayStatus(relayUrl, true) - // Process any queued messages for this relay - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val queued = iterator.next() - if (queued.accountGeneration == generation && - isCurrentAccountGeneration(generation) && - relayUrl in queued.targetRelays && - isNetworkActionAllowed(queued.liveLocationToken) - ) { - sendToRelay( - queued.event, - webSocket, - relayUrl, - queued.liveLocationToken, - generation - ) - } - } + // Restore all active subscriptions for this relay + restoreSubscriptionsForRelay(relayUrl, webSocket, generation) + + // Process only events still pending for this relay, outside the queue lock. + val queuedForRelay = messageQueue.pendingForRelay( + relayUrl = relayUrl, + accountGeneration = generation + ).filter { isNetworkActionAllowed(it.liveLocationToken) } + queuedForRelay.forEach { delivery -> + if (delivery.accountGeneration == generation && + isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket && + sendToRelay( + delivery.event, + webSocket, + relayUrl, + delivery.liveLocationToken, + generation + ) + ) { + messageQueue.markDelivered(delivery.queueId, relayUrl) } } + + val connectionHandler = ndrConnectionAvailableHandler + if (connectionHandler?.accountGeneration == generation && + isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + runCatching { connectionHandler.callback() } + .onFailure { + Log.w(TAG, "NDR reconnect callback failed") + } + } } override fun onMessage(webSocket: WebSocket, text: String) { + if (!isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket + ) return handleMessage(text, relayUrl, generation) } @@ -1586,32 +1905,24 @@ class NostrRelayManager internal constructor( } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - if (!isCurrentAccountGeneration(generation)) { - connections.remove(relayUrl, webSocket) - return - } val error = Exception("WebSocket closed: $code $reason") handleDisconnection( relayUrl, + webSocket, error, liveLocationToken, - generation, - webSocket + generation ) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - if (!isCurrentAccountGeneration(generation)) { - connections.remove(relayUrl, webSocket) - return - } Log.e(TAG, "Nostr WebSocket failure") handleDisconnection( relayUrl, + webSocket, t, liveLocationToken, - generation, - webSocket + generation ) } } 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 66e9cf1e..bb8bca17 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -7,12 +7,17 @@ import kotlinx.coroutines.CoroutineScope /** * NostrSubscriptionManager - * - Encapsulates subscription lifecycle with NostrRelayManager + * - Encapsulates ordered subscription lifecycle with NostrRelayManager. + * + * Relay-manager operations are already non-blocking and schedule network I/O on their own scope. + * Keeping this facade synchronous prevents lifecycle cancellation from dropping unsubscribe work + * and guarantees that a channel switch closes the old subscription before opening the new one. */ class NostrSubscriptionManager( private val application: Application, @Suppress("UNUSED_PARAMETER") - private val scope: CoroutineScope + private val scope: CoroutineScope? = null, + private val owner: String = NostrRelayManager.OWNER_LEGACY ) { companion object { private const val TAG = "NostrSubscriptionManager" } @@ -28,13 +33,22 @@ class NostrSubscriptionManager( .onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } - fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { + fun subscribeGiftWraps( + pubkey: String, + sinceMs: Long, + id: String, + handler: (NostrEvent) -> Unit, + liveLocationToken: Long? = null + ) { + if (!isAllowed(liveLocationToken)) return val generation = relayManager.captureAccountGeneration() val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) relayManager.subscribe( filter = filter, id = id, handler = handler, + owner = owner, + liveLocationToken = liveLocationToken, expectedAccountGeneration = generation ) } @@ -48,9 +62,7 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return + if (!isAllowed(liveLocationToken)) return val generation = relayManager.captureAccountGeneration() val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) relayManager.subscribeForGeohash( @@ -60,6 +72,7 @@ class NostrSubscriptionManager( handler, includeDefaults = false, nRelays = 5, + owner = owner, liveLocationToken = liveLocationToken, expectedAccountGeneration = generation ) @@ -74,9 +87,7 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return + if (!isAllowed(liveLocationToken)) return val generation = relayManager.captureAccountGeneration() val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) relayManager.subscribeForGeohash( @@ -86,12 +97,32 @@ class NostrSubscriptionManager( handler, includeDefaults = false, nRelays = 5, + owner = owner, liveLocationToken = liveLocationToken, expectedAccountGeneration = generation ) } fun unsubscribe(id: String) { - runCatching { relayManager.unsubscribe(id) } + val generation = relayManager.captureAccountGeneration() + runCatching { + relayManager.unsubscribe( + id, + expectedAccountGeneration = generation + ) + } } + + fun unsubscribeAllOwned() { + val generation = relayManager.captureAccountGeneration() + runCatching { + relayManager.unsubscribeOwner( + owner, + expectedAccountGeneration = generation + ) + } + } + + private fun isAllowed(liveLocationToken: Long?): Boolean = + liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 84fd2a14..afb92450 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -65,6 +65,9 @@ private data class NdrRecipientResolution( val rebindBlocked: Boolean ) +@JvmInline +internal value class NostrTransportResetToken(val epoch: Long) + /** * Nostr transport for offline private messages and receipts. */ @@ -141,22 +144,26 @@ class NostrTransport( * token and is refused at the final relay handoff; throttled receipts are * discarded immediately. */ - fun discardForAccountReset(): Long = + internal fun discardForAccountReset(): NostrTransportResetToken = synchronized(accountStateLock) { accountResetBlocked = true transportAccountEpoch += 1 readQueue.clear() activeReadSequence = null - transportAccountEpoch + NostrTransportResetToken(transportAccountEpoch) } /** * Allow fresh work only when the caller still owns the latest reset. * A later panic/quit must not be reopened by an older reset finishing late. */ - fun completeAccountReset(resetToken: Long): Boolean = + internal fun completeAccountReset( + resetToken: NostrTransportResetToken + ): Boolean = synchronized(accountStateLock) { - if (resetToken != transportAccountEpoch) return@synchronized false + if (resetToken.epoch != transportAccountEpoch) { + return@synchronized false + } accountResetBlocked = false true } diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 50a58beb..14fbb06d 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -37,6 +37,9 @@ import com.bitchat.android.R /** * Explanation screen shown before requesting background location permission. + * + * Bottom action layout matches the battery optimization skip screen so the + * primary / check-again / skip buttons stay pixel-aligned across both screens. */ @Composable fun BackgroundLocationPermissionScreen( @@ -46,132 +49,125 @@ fun BackgroundLocationPermissionScreen( onSkip: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val scrollState = rememberScrollState() - Box(modifier = modifier) { + Box( + modifier = modifier.padding(24.dp), + contentAlignment = Alignment.Center + ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) - .padding(bottom = 88.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(24.dp)) - - HeaderSection(colorScheme) - - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.LocationOn, - contentDescription = stringResource(R.string.cd_location_services), - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Column { - Text( - text = stringResource(R.string.background_location_required_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.background_location_explanation), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onBackground.copy(alpha = 0.8f) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.background_location_settings_tip), - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = BitchatFontFamily - ), - color = colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_privacy_protected), - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Column { - Text( - text = stringResource(R.string.background_location_needs_for), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.background_location_needs_bullets), - style = MaterialTheme.typography.bodySmall, - fontFamily = BitchatFontFamily, - color = colorScheme.onBackground.copy(alpha = 0.8f) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.background_location_privacy_note), - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = BitchatFontFamily, - fontWeight = FontWeight.Medium - ), - color = colorScheme.onBackground - ) - } - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - } - - Surface( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(), - color = colorScheme.surface, - shadowElevation = 8.dp + modifier = Modifier.fillMaxSize() ) { + // Scrollable content area Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderSection(colorScheme) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = stringResource(R.string.cd_location_services), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_required_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_explanation), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_settings_tip), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily + ), + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Security, + contentDescription = stringResource(R.string.cd_privacy_protected), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_needs_for), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_needs_bullets), + style = MaterialTheme.typography.bodySmall, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_privacy_note), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium + ), + color = colorScheme.onBackground + ) + } + } + } + } + } + + // Fixed buttons at the bottom — same structure/spacing as battery optimization + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Button( onClick = onContinue, @@ -185,8 +181,7 @@ fun BackgroundLocationPermissionScreen( style = MaterialTheme.typography.bodyMedium.copy( fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold - ), - modifier = Modifier.padding(vertical = 4.dp) + ) ) } 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 89b86a6d..0c9dbbf7 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -54,34 +54,21 @@ object AppShutdownCoordinator { try { // Quit is an account-lifetime boundary, not a transient service // pause: no queued plaintext or relay event may survive it. - try { - com.bitchat.android.nostr.NostrTransport - .tryGetInstance() - ?.discardForAccountReset() - } catch (_: Exception) { } - try { - com.bitchat.android.services.MessageRouter - .tryGetInstance() - ?.discardForAccountReset() - } catch (_: Exception) { } - try { - com.bitchat.android.nostr.NostrInboundAccountLifecycle - .invalidate() - } catch (_: Exception) { } - val relayManager = runCatching { - com.bitchat.android.nostr.NostrRelayManager.getInstance(app) - }.getOrNull() - val relayResetToken = runCatching { - relayManager?.beginAccountReset() + val accountReset = runCatching { + com.bitchat.android.nostr.AccountResetCoordinator.begin( + application = app, + terminal = true + ) }.getOrNull() try { com.bitchat.android.nostr.NdrNostrService .getInstance(app) .shutdownForProcessExit() } catch (_: Exception) { } - if (relayResetToken != null) { + if (accountReset != null) { runCatching { - relayManager?.discardForAccountReset(relayResetToken) + com.bitchat.android.nostr.AccountResetCoordinator + .discardRelay(accountReset) } } @@ -94,6 +81,11 @@ object AppShutdownCoordinator { // Stop mesh (best-effort) try { mesh?.stopServices() } 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 79dad672..bb2e1799 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() { @@ -112,9 +112,11 @@ class MeshForegroundService : Service() { private val unifiedMeshService: com.bitchat.android.mesh.MeshService? get() = MeshServiceHolder.unifiedMeshService private val serviceJob = Job() - private val scope = CoroutineScope(Dispatchers.Default + serviceJob) + // Service lifecycle callbacks and notification state are main-thread confined. + private val scope = CoroutineScope(Dispatchers.Main.immediate + serviceJob) private var isInForeground: Boolean = false private var isShuttingDown: Boolean = false + private var lastNotifiedPeerCount: Int? = null override fun onCreate() { super.onCreate() @@ -142,6 +144,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 { @@ -166,7 +178,7 @@ class MeshForegroundService : Service() { try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { } try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { } try { MeshServiceHolder.clear() } catch (_: Exception) { } - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) isInForeground = false stopSelf() @@ -176,7 +188,7 @@ class MeshForegroundService : Service() { isShuttingDown = true updateJob?.cancel() updateJob = null - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) isInForeground = false // Fully stop all background activity, stop Tor (without changing setting), then kill the app @@ -185,7 +197,7 @@ class MeshForegroundService : Service() { mesh = unifiedMeshService, notificationManager = notificationManager, stopForeground = { - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } isInForeground = false }, stopService = { stopSelf() } @@ -195,9 +207,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) } @@ -210,32 +224,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 @@ -249,19 +242,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}") @@ -274,14 +257,23 @@ class MeshForegroundService : Service() { return } val count = getUnifiedActivePeerCount() - val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { - notificationManager.notify(NOTIFICATION_ID, notification) + if (Build.VERSION.SDK_INT >= 33 && + androidx.core.content.ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) != android.content.pm.PackageManager.PERMISSION_GRANTED + ) return + 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) { } + try { stopForeground(STOP_FOREGROUND_DETACH) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) isInForeground = false + lastNotifiedPeerCount = null } } @@ -413,42 +405,28 @@ class MeshForegroundService : Service() { try { serviceJob.cancel() } catch (_: Exception) { } // Best-effort ensure we are not marked foreground if (isInForeground) { - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } isInForeground = false } super.onDestroy() } private fun discardOldAccountNetworkWork() { - try { - com.bitchat.android.nostr.NostrTransport - .tryGetInstance() - ?.discardForAccountReset() - } catch (_: Exception) { } - try { - com.bitchat.android.services.MessageRouter - .tryGetInstance() - ?.discardForAccountReset() - } catch (_: Exception) { } - try { - com.bitchat.android.nostr.NostrInboundAccountLifecycle - .invalidate() - } catch (_: Exception) { } - val relayManager = runCatching { - com.bitchat.android.nostr.NostrRelayManager - .getInstance(applicationContext) - }.getOrNull() - val relayResetToken = runCatching { - relayManager?.beginAccountReset() + val accountReset = runCatching { + com.bitchat.android.nostr.AccountResetCoordinator.begin( + application = application, + terminal = true + ) }.getOrNull() try { com.bitchat.android.nostr.NdrNostrService .getInstance(applicationContext) .shutdownForProcessExit() } catch (_: Exception) { } - if (relayResetToken != null) { + if (accountReset != null) { runCatching { - relayManager?.discardForAccountReset(relayResetToken) + com.bitchat.android.nostr.AccountResetCoordinator + .discardRelay(accountReset) } } } 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 bbc9965b..b6256afe 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() @@ -29,6 +31,12 @@ object AppStateStore { private val _privateMessages = MutableStateFlow>>(emptyMap()) val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() + + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() + // Channel messages by channel name private val _channelMessages = MutableStateFlow>>(emptyMap()) val channelMessages: StateFlow>> = _channelMessages.asStateFlow() @@ -39,6 +47,14 @@ object AppStateStore { } } + fun setNickname(nickname: String) { + _nickname.value = nickname + } + + fun setSelectedPrivateChatPeer(peerID: String?) { + _selectedPrivateChatPeer.value = peerID + } + fun setTransportPeers(transportId: String, ids: List) { synchronized(this) { peerIdsByTransport[transportId] = ids.toSet() @@ -69,20 +85,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) { @@ -99,6 +120,7 @@ object AppStateStore { synchronized(this) { if (seenMessageIds.contains(msg.id)) return seenMessageIds.add(msg.id) + PrivateMessageArrivalOrder.record(msg.id) val conversationID = ContactDirectory.canonicalConversationId(peerID) val map = _privateMessages.value.toMutableMap() val list = (map[conversationID] ?: emptyList()) + msg @@ -217,12 +239,16 @@ object AppStateStore { synchronized(this) { seenMessageIds.clear() seenPublicMessageKeys.clear() + PrivateMessageArrivalOrder.clear() peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() + _directPeers.value = emptySet() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() + _nickname.value = "" + _selectedPrivateChatPeer.value = null } } diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index b3ffc429..28cb695c 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -102,9 +102,10 @@ object ContactDirectory { } return merged.mapValues { (_, messages) -> - messages - .distinctBy { it.id } - .sortedWith(compareBy { it.timestamp.time }.thenBy { it.id }) + // A private message's timestamp comes from the sender and is not a reliable ordering + // signal when peers' clocks differ. Use the local receipt sequence so interleaved + // alias lists can be merged back into their global arrival order. + PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) } } diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index e3c2c61d..96fa15b8 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -65,7 +65,6 @@ object ConversationAliasResolver { if (didMerge) { currentChats[targetConversationID] = targetList .distinctBy { it.id } - .sortedBy { it.timestamp.time } state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentChats)) // Move unread flags diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index 9dddcbea..dfd379d0 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -30,6 +30,9 @@ internal fun interface NostrPrivateMessageSender { ) } +@JvmInline +internal value class MessageRouterResetToken(val epoch: Long) + class MessageRouter internal constructor( private val context: Context, private var mesh: MeshService, @@ -508,24 +511,32 @@ class MessageRouter internal constructor( /** Panic/reset must invalidate callbacks from the previous account and drop plaintext. */ @Synchronized - fun discardForAccountReset(): Long { + internal fun discardForAccountReset(): MessageRouterResetToken { stopOutboxScheduler() accountResetBlocked = true outboxEpoch += 1 inFlightNostrAttempts.clear() outbox.clear() retryState.clear() - return outboxEpoch + return MessageRouterResetToken(outboxEpoch) } @Synchronized - fun completeAccountReset(resetToken: Long): Boolean { - if (resetToken != outboxEpoch) return false + internal fun completeAccountReset(resetToken: MessageRouterResetToken): Boolean { + if (resetToken.epoch != outboxEpoch) return false accountResetBlocked = false startOutboxScheduler() return true } + @Synchronized + internal fun installReplacementMeshForAccountReset(replacement: MeshService) { + check(accountResetBlocked) { + "Replacement mesh may only be installed behind an account reset barrier" + } + mesh = replacement + } + internal val queuedMessageCount: Int @Synchronized get() = outbox.values.sumOf { it.size } diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt new file mode 100644 index 00000000..a5c92453 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage + +/** + * Process-local receipt sequence for private messages. + * + * Peer-provided timestamps cannot safely order a conversation because clocks can differ. This + * registry follows the lifetime of [AppStateStore] and lets alias merges reconstruct the order in + * which messages entered the app, even when they were initially stored under different keys. + */ +internal object PrivateMessageArrivalOrder { + private val sequenceByMessageID = mutableMapOf() + private var nextSequence = 0L + + fun record(messageID: String) { + synchronized(this) { + if (messageID !in sequenceByMessageID) { + sequenceByMessageID[messageID] = nextSequence++ + } + } + } + + fun order(messages: List): List { + synchronized(this) { + if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) { + return messages + } + return messages.sortedBy { sequenceByMessageID.getValue(it.id) } + } + } + + fun clear() { + synchronized(this) { + sequenceByMessageID.clear() + nextSequence = 0L + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 13c754a8..f4504cc2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -216,6 +216,7 @@ class ChatState( fun setNickname(nickname: String) { _nickname.value = nickname + com.bitchat.android.services.AppStateStore.setNickname(nickname) } fun setIsConnected(connected: Boolean) { @@ -228,6 +229,7 @@ class ChatState( fun setSelectedPrivateChatPeer(peerID: String?) { _selectedPrivateChatPeer.value = peerID + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(peerID) } fun setUnreadPrivateMessages(unread: Set) { 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 ddd6dde4..ced52cb2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -24,6 +24,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.nostr.AccountResetCoordinator import com.bitchat.android.nostr.NdrBootstrapAction import com.bitchat.android.nostr.NdrBootstrapDecider import com.bitchat.android.nostr.NdrBootstrapTriggerCoordinator @@ -46,6 +47,7 @@ import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay import java.util.Date import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import kotlin.random.Random import com.bitchat.android.services.VerificationService import com.bitchat.android.identity.SecureIdentityStateManager @@ -76,6 +78,8 @@ class ChatViewModel( private const val TAG = "ChatViewModel" } + private val panicResetInProgress = AtomicBoolean(false) + fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath) } @@ -194,8 +198,6 @@ class ChatViewModel( application = application, state = state, messageManager = messageManager, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, dataManager = dataManager, notificationManager = notificationManager ) @@ -549,6 +551,8 @@ class ChatViewModel( ndrService.onOutOfBandPayload = null } ndrInviteRetries.cancelAll() + geohashViewModel.shutdownUiSubscriptions() + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } @@ -1381,24 +1385,27 @@ class ChatViewModel( // MARK: - Emergency Clear fun panicClearAllData() { + if (!panicResetInProgress.compareAndSet(false, true)) return + viewModelScope.launch(Dispatchers.Default) { + try { + performPanicClearAllData() + } finally { + panicResetInProgress.set(false) + } + } + } + + private fun performPanicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") - // Freeze transport admission before erasing router plaintext so a - // concurrent UI send cannot create fresh work in the reset window. - val resetTransport = com.bitchat.android.nostr.NostrTransport - .getInstance(getApplication()) - val transportResetToken = resetTransport.discardForAccountReset() - val resetRouter = com.bitchat.android.services.MessageRouter - .getInstance(getApplication(), mesh) - val routerResetToken = resetRouter.discardForAccountReset() - geohashViewModel.invalidateDoubleRatchetAccount() + val resetLease = AccountResetCoordinator.begin(getApplication()) ?: run { + Log.w(TAG, "Panic reset ignored after terminal application shutdown") + return + } try { com.bitchat.android.geohash.LocationChannelManager .getInstance(getApplication()) .disableLocationServices() } catch (_: Exception) { } - val resetRelay = com.bitchat.android.nostr.NostrRelayManager - .getInstance(getApplication()) - val relayResetToken = resetRelay.beginAccountReset() // A pending one-shot downgrade confirmation must not survive panic or // become actionable against the fresh post-wipe identity. @@ -1410,7 +1417,10 @@ class ChatViewModel( // NDR reset is synchronized and therefore quiesces any native send that // entered before panic. Advance the relay generation only afterwards, // then clear every event produced by that old runtime. - resetRelay.discardForAccountReset(relayResetToken) + if (!AccountResetCoordinator.discardRelay(resetLease)) { + Log.w(TAG, "A newer account reset superseded relay cleanup") + return + } ndrBootstrapAttemptMs.clear() ndrNoiseHandshakeAttemptMs.clear() ndrInviteRetries.cancelAll() @@ -1486,7 +1496,7 @@ class ChatViewModel( locationManager.clearPersistedChannel() } catch (_: Exception) { } - geohashViewModel.panicReset(reinitialize = false) + geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") } @@ -1496,25 +1506,25 @@ class ChatViewModel( state.setNickname(newNickname) dataManager.saveNickname(newNickname) - // Recreate mesh service with fresh identity - recreateMeshServiceAfterPanic() try { - resetTransport.senderPeerID = mesh.myPeerID - val transportReopened = - resetTransport.completeAccountReset(transportResetToken) - val routerReopened = - resetRouter.completeAccountReset(routerResetToken) - val relayReopened = - resetRelay.completeAccountReset(relayResetToken) - if (!transportReopened || !routerReopened || !relayReopened) { + val reopened = AccountResetCoordinator.complete( + lease = resetLease, + installReplacement = { + recreateMeshServiceAfterPanic() + mesh + }, + startReplacement = { replacement -> + replacement.startServices() + replacement.sendBroadcastAnnounce() + } + ) + if (!reopened) { Log.w(TAG, "A newer account reset superseded panic reinitialization") return } - geohashViewModel.initialize() - mesh.startServices() - mesh.sendBroadcastAnnounce() } catch (e: Exception) { Log.e(TAG, "Failed to reopen network transports after panic: ${e.message}") + return } Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 4f37d47a..8517ec15 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -198,8 +198,10 @@ class DataManager(private val context: Context) { // MARK: - Blocked Users Management + @Synchronized fun loadBlockedUsers() { val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() + _blockedUsers.clear() _blockedUsers.addAll(savedBlockedUsers) } @@ -217,6 +219,7 @@ class DataManager(private val context: Context) { saveBlockedUsers() } + @Synchronized fun isUserBlocked(fingerprint: String): Boolean { return _blockedUsers.contains(fingerprint) } @@ -226,8 +229,10 @@ class DataManager(private val context: Context) { private val _geohashBlockedUsers = mutableSetOf() // Set of nostr pubkey hex val geohashBlockedUsers: Set get() = _geohashBlockedUsers.toSet() + @Synchronized fun loadGeohashBlockedUsers() { val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet() + _geohashBlockedUsers.clear() _geohashBlockedUsers.addAll(savedGeohashBlockedUsers) } @@ -245,6 +250,7 @@ class DataManager(private val context: Context) { saveGeohashBlockedUsers() } + @Synchronized fun isGeohashUserBlocked(pubkeyHex: String): Boolean { return _geohashBlockedUsers.contains(pubkeyHex) } 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 da236e89..4b2b6254 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -12,7 +12,7 @@ import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy import com.bitchat.android.geohash.LiveLocationPrivacyGate 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 @@ -20,69 +20,47 @@ import com.bitchat.android.nostr.NostrSubscriptionManager import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine 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 java.util.UUID -import kotlin.random.asKotlinRandom class GeohashViewModel( application: Application, private val state: ChatState, private val messageManager: MessageManager, - private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, private val dataManager: DataManager, 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-${UUID.randomUUID()}" + private val subscriptionManager = NostrSubscriptionManager( + application, + owner = uiSubscriptionOwner + ) private val geohashMessageHandler = GeohashMessageHandler( application = application, - state = state, - messageManager = messageManager, repo = repo, - scope = viewModelScope, - dataManager = dataManager - ) - private val dmHandler = NostrDirectMessageHandler( - application = application, - state = state, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, - scope = viewModelScope, - repo = repo, - dataManager = dataManager + dataManager = dataManager, + addChannelMessage = messageManager::addChannelMessage ) - // 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 currentDmGeohash: 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() private val samplingSubscriptionIds = mutableMapOf() private val liveSamplingSubscriptionGeohashes = mutableSetOf() private var requestedLiveSamplingGeohashes: Set = emptySet() private var requestedUserSamplingGeohashes: Set = emptySet() + private var uiSubscriptionsShutdown = false private val liveLocationRevocationListener: () -> Unit = { val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet() revokedLiveGeohashes.forEach { geohash -> @@ -105,26 +83,10 @@ class GeohashViewModel( } 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) { - // Configure the singleton only for the account identity, never a derived geohash key. - val accountEpoch = dmHandler.configureAccount(identity) - // 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 -> - // geohash="" means global account DM (not geohash identity). - dmHandler.onGiftWrap(event, "", identity, accountEpoch) - } - ) - } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) viewModelScope.launch { @@ -138,10 +100,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) @@ -149,129 +107,27 @@ class GeohashViewModel( } } - private fun startGlobalPresenceHeartbeat() { - globalPresenceJob?.cancel() - globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val manager = locationChannelManager ?: return@launch - combine( - manager.availableChannels, - LiveLocationPrivacyGate.enabled - ) { channels, enabled -> - GeohashNostrPrivacyPolicy.livePresenceTargets(channels, enabled) - }.collectLatest { targetGeohashes -> - - 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 - - broadcastLiveLocationPresence(geohash) - } - } catch (e: CancellationException) { - throw e - } 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(reinitialize: Boolean = true) { - dmHandler.invalidateAccount() + fun panicReset() { repo.clearAll() GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() - subscriptionManager.disconnect() - currentGeohashMsgSubId = null + subscriptionManager.unsubscribeAllOwned() currentGeohashPresenceSubId = null - currentDmSubId = null - currentDmGeohash = null activeChannelGeohash = null geoTimer?.cancel() geoTimer = null - globalPresenceJob?.cancel() - globalPresenceJob = null try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} - // Install the replacement account key while relay/transport admission is - // still blocked. The caller may defer network initialization until the - // rest of the account identity has been recreated. - try { NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) } catch (_: Exception) {} - if (reinitialize) { - initialize() - } - } - - fun invalidateDoubleRatchetAccount() { - dmHandler.invalidateAccount() - } - - private suspend fun broadcastLiveLocationPresence(geohash: String) { - val manager = locationChannelManager ?: return - val token = LiveLocationPrivacyGate.captureToken() ?: return - val isCurrentLiveTarget = GeohashNostrPrivacyPolicy.livePresenceTargets( - manager.availableChannels.value, - liveLocationEnabled = true - ).contains(geohash) - if (!isCurrentLiveTarget || !LiveLocationPrivacyGate.accepts(token)) return - - try { - var identity: com.bitchat.android.nostr.NostrIdentity? = null - LiveLocationPrivacyGate.runIfAllowed(token) { - identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - } - val preparedIdentity = identity ?: return - if (!LiveLocationPrivacyGate.accepts(token)) return - val event = NostrProtocol.createGeohashPresenceEvent(geohash, preparedIdentity) - LiveLocationPrivacyGate.runIfAllowed(token) { - val relayManager = NostrRelayManager.getInstance(getApplication()) - relayManager.sendEventToGeohash( - event, - geohash, - includeDefaults = false, - nRelays = 5, - liveLocationToken = token - ) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to send live-location presence") - } } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { - val accountEpoch = dmHandler.currentAccountEpoch() ?: return - val accountContext = - com.bitchat.android.nostr.NostrInboundAccountLifecycle - .contextFor(accountEpoch) - ?: return val relayManager = NostrRelayManager.getInstance(getApplication()) - val relayGeneration = relayManager.captureAccountGeneration() - viewModelScope.launch(Dispatchers.Default + accountContext.receiveJob) { + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) return + viewModelScope.launch { try { - if (!com.bitchat.android.nostr.NostrInboundAccountLifecycle - .isCurrent(accountEpoch) || - !relayManager.isAccountGenerationCurrent(relayGeneration) - ) return@launch + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + return@launch + } val canUseChannel = locationChannelManager ?.canUseSelectedLocationChannel(channel) == true if (!canUseChannel) { @@ -283,6 +139,9 @@ class GeohashViewModel( val liveLocationToken = locationChannelManager ?.liveLocationTokenForSelectedChannel(channel) if (isLiveDerived && liveLocationToken == null) return@launch + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + return@launch + } val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}" val pow = PoWPreferenceManager.getCurrentSettings() val localMsg = com.bitchat.android.model.BitchatMessage( @@ -295,22 +154,11 @@ class GeohashViewModel( channel = "#${channel.geohash}", powDifficulty = if (pow.enabled) pow.difficulty else null ) - val localEchoAdded = - com.bitchat.android.nostr.NostrInboundAccountLifecycle - .runIfCurrent(accountEpoch) { - messageManager.addChannelMessage( - "geo:${channel.geohash}", - localMsg - ) - } - if (!localEchoAdded) return@launch + messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) val identity = NostrIdentityBridge.deriveIdentity( forGeohash = channel.geohash, context = getApplication() ) - if (!com.bitchat.android.nostr.NostrInboundAccountLifecycle - .isCurrent(accountEpoch) - ) return@launch val teleported = locationChannelManager?.teleported?.value ?: state.isTeleported.value val event = NostrProtocol.createEphemeralGeohashEvent( @@ -326,7 +174,7 @@ class GeohashViewModel( includeDefaults = false, nRelays = 5, liveLocationToken = liveLocationToken, - expectedAccountGeneration = relayGeneration + expectedAccountGeneration = accountGeneration ) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") @@ -462,27 +310,36 @@ class GeohashViewModel( } fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) { - val geohash = repo.getConversationGeohash(conversationKey) ?: return - if (currentDmGeohash == geohash && currentDmSubId != null) return - - currentDmSubId?.let(subscriptionManager::unsubscribe) - currentDmSubId = null - currentDmGeohash = null - subscribeChannelDM(geohash) + val geohash = repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) + ?: return + NostrBackgroundRuntime.ensureConversationDm(geohash) } - fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) - fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { + val foregroundName = repo.displayNameForNostrPubkeyUI(pubkeyHex) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForNostrPubkey(pubkeyHex) + ?: foregroundName + } + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { + val foregroundName = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + ?: foregroundName + } + + fun conversationGeohash(conversationKey: String): String? = + repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = PeerIdentity.nostr(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 } - currentDmGeohash = null when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { @@ -519,17 +376,11 @@ class GeohashViewModel( val liveLocationToken = locationChannelManager ?.liveLocationTokenForSelectedChannel(channel.channel) - // Chat message stream (kind 20000) is low-volume; keep it alive even when - // backgrounded so geohash messages still arrive. - subscribeChannelMessages(channel.channel.geohash, liveLocationToken) // 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, liveLocationToken) } - // 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") @@ -539,28 +390,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, - liveLocationToken: Long? - ) { - val accountEpoch = dmHandler.currentAccountEpoch() ?: return - val subId = "geohash-${UUID.randomUUID()}"; currentGeohashMsgSubId = subId - subscriptionManager.subscribeGeohashMessages( - geohash = geohash, - sinceMs = System.currentTimeMillis() - 3600000L, - limit = 200, - id = subId, - handler = { event -> - geohashMessageHandler.onEvent(event, geohash, accountEpoch) - }, - liveLocationToken = liveLocationToken - ) - } - /** * 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 @@ -570,7 +399,7 @@ class GeohashViewModel( geohash: String, liveLocationToken: Long? ) { - val accountEpoch = dmHandler.currentAccountEpoch() ?: return + val accountEpoch = NostrBackgroundRuntime.currentAccountEpoch() ?: return val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId subscriptionManager.subscribeGeohashPresence( geohash = geohash, @@ -584,32 +413,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) { - val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val accountEpoch = dmHandler.currentAccountEpoch() - if (accountEpoch == null) { - Log.w(TAG, "Skipping geohash DM subscription without an active account epoch") - return - } - val dmSubId = "geo-dm-${UUID.randomUUID()}" - currentDmSubId = dmSubId - currentDmGeohash = geohash - subscriptionManager.subscribeGiftWraps( - pubkey = dmIdentity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = dmSubId, - handler = { event -> - dmHandler.onGiftWrap(event, geohash, dmIdentity, accountEpoch) - } - ) - // 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) { @@ -620,7 +423,16 @@ class GeohashViewModel( } override fun onCleared() { + shutdownUiSubscriptions() super.onCleared() + } + + fun shutdownUiSubscriptions() { + if (uiSubscriptionsShutdown) return + uiSubscriptionsShutdown = true + subscriptionManager.unsubscribeAllOwned() + geoTimer?.cancel() + geoTimer = null kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } @@ -653,10 +465,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) { @@ -666,16 +474,13 @@ class GeohashViewModel( currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } // Drop geohash sampling subscriptions activeSamplingGeohashes.forEach(::unsubscribeSampling) - // 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) { - val accountEpoch = dmHandler.currentAccountEpoch() ?: return + val accountEpoch = NostrBackgroundRuntime.currentAccountEpoch() ?: return val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) { "sampling-${UUID.randomUUID()}" } @@ -711,11 +516,7 @@ class GeohashViewModel( limit = 200, id = subscriptionId, handler = { event -> - geohashMessageHandler.onEvent( - event, - geohash, - accountEpoch - ) + geohashMessageHandler.onEvent(event, geohash, accountEpoch) }, liveLocationToken = token ) diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index a7285aea..ff3a85d4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.PrivateMessageArrivalOrder import java.util.* import java.util.Collections @@ -110,6 +111,8 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages + // Record the local arrival sequence before canonicalizing UI aliases. + PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) // Reflect into process-wide store try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } @@ -132,6 +135,8 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages + // Record the local arrival sequence before canonicalizing UI aliases. + PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) // Reflect into process-wide store try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt new file mode 100644 index 00000000..b6eac7cd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Stable, presentation-neutral identity used to derive a peer hue. + * + * ViewModels may expose this value, but only the UI theme resolves it to a rendered color. + */ +@JvmInline +value class PeerColorSeed(val value: String) + +fun meshPeerColorSeed(peerID: String): PeerColorSeed = + PeerColorSeed("noise:${peerID.lowercase(Locale.ROOT)}") + +fun nostrPeerColorSeed(pubkeyHex: String): PeerColorSeed = + PeerColorSeed("nostr:${pubkeyHex.lowercase(Locale.ROOT)}") + +fun peerColorSeedForMessage(message: BitchatMessage): PeerColorSeed { + val value = when { + message.senderPeerID?.startsWith("nostr:") == true || + message.senderPeerID?.startsWith("nostr_") == true -> { + "nostr:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + message.senderPeerID?.length == 16 || message.senderPeerID?.length == 64 -> { + "noise:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + else -> message.sender.lowercase(Locale.ROOT) + } + + return PeerColorSeed(value) +} diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index bd1b51bf..ee28a534 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -34,6 +34,7 @@ class PrivateChatManager( private val messageManager: MessageManager, private val dataManager: DataManager, private val noiseSessionDelegate: NoiseSessionDelegate, + private val trackUnreadMessages: Boolean = true, private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false }, private val markMessageReadLocally: (messageID: String) -> Unit = {} ) { @@ -343,7 +344,7 @@ class PrivateChatManager( // Nostr messages originate here and must be added explicitly, even after their // sender alias has canonicalized to a contact_* conversation ID. if (origin == PrivateMessageOrigin.NOSTR) { - if (suppressUnread) { + if (suppressUnread || !trackUnreadMessages) { messageManager.addPrivateMessageNoUnread(conversationID, message) } else { messageManager.addPrivateMessage(conversationID, message) @@ -351,7 +352,10 @@ class PrivateChatManager( } // Track as unread for read receipt purposes if not focused - if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) { + if (trackUnreadMessages && + !suppressUnread && + state.getSelectedPrivateChatPeerValue() != conversationID + ) { val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() } unreadList.add(message) Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})") 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 ad2325ed..0210f58c 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -92,7 +92,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 e6863e14..f7b3c1ec 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 @@ -78,10 +78,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L private const val CLIENT_SOCKET_ATTEMPTS = 3 private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 - // 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:" } @@ -91,6 +87,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 @@ -173,6 +170,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor hooks = MeshCore.Hooks( onMessageReceived = { message -> handleMessageReceived(message) }, onAnnounceProcessed = { routed, _ -> + publishControllerDebugSnapshot() routed.peerID?.let { pid -> DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) ?.let(::observeDirectIngressLink) @@ -628,6 +626,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) @@ -644,7 +643,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)") @@ -659,7 +659,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() @@ -667,18 +668,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 @@ -728,7 +730,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) } } @@ -871,6 +873,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) @@ -938,30 +941,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)) } } @@ -1129,6 +1110,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.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} listenerExec.execute { listenToPeer(synced, peerId) } @@ -1172,28 +1154,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)) } } } @@ -1271,6 +1275,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor ) } catch (_: Exception) { } try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { } + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000) + } catch (_: Exception) { } + publishControllerDebugSnapshot() Log.i( TAG, @@ -1357,6 +1365,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() } } @@ -1584,6 +1593,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/nostr/AccountResetGateTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/AccountResetGateTest.kt new file mode 100644 index 00000000..8bb9dfaf --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/AccountResetGateTest.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class AccountResetGateTest { + @Test + fun supersededLeaseCannotReopenOrRunOwnedMutation() { + val gate = AccountResetGate() + val first = gate.begin() + val second = gate.begin() + var firstMutationRan = false + + assertFalse( + gate.runIfCurrent(first) { + firstMutationRan = true + } + ) + assertFalse(firstMutationRan) + assertTrue(gate.runIfCurrent(second) {}) + } + + @Test + fun ownedMutationFinishesBeforeNextResetStarts() { + val gate = AccountResetGate() + val first = gate.begin() + val mutationStarted = CountDownLatch(1) + val releaseMutation = CountDownLatch(1) + val resetAttempted = CountDownLatch(1) + val resetFinished = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + + try { + executor.submit { + gate.runIfCurrent(first) { + mutationStarted.countDown() + assertTrue(releaseMutation.await(2, TimeUnit.SECONDS)) + } + } + assertTrue(mutationStarted.await(2, TimeUnit.SECONDS)) + + executor.submit { + resetAttempted.countDown() + gate.begin() + resetFinished.countDown() + } + + assertTrue(resetAttempted.await(2, TimeUnit.SECONDS)) + assertFalse(resetFinished.await(100, TimeUnit.MILLISECONDS)) + releaseMutation.countDown() + assertTrue(resetFinished.await(2, TimeUnit.SECONDS)) + } finally { + releaseMutation.countDown() + executor.shutdownNow() + } + } + + @Test(expected = IllegalStateException::class) + fun ownedMutationCannotRecursivelyBeginReset() { + val gate = AccountResetGate() + val lease = gate.begin() + + gate.runIfCurrent(lease) { + gate.begin() + } + } + + @Test + fun terminalResetRejectsAllLaterResets() { + val gate = AccountResetGate() + val terminal = gate.begin(terminal = true) + + assertTrue(terminal != null) + assertEquals(null, gate.begin(terminal = false)) + assertEquals(null, gate.begin(terminal = true)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt new file mode 100644 index 00000000..f154ec1e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -0,0 +1,72 @@ +package com.bitchat.android.nostr + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.NdrFeatureGate +import com.bitchat.android.services.AppStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrBackgroundEventProcessorTest { + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + AppStateStore.clear() + NdrFeatureGate.setEnabledForTests(false) + NostrInboundAccountLifecycle.invalidate() + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + + @After + fun tearDown() { + NostrInboundAccountLifecycle.invalidate() + NdrFeatureGate.setEnabledForTests(false) + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking { + val application = ApplicationProvider.getApplicationContext() + val processor = NostrBackgroundEventProcessor(application, scope) + val accountEpoch = processor.configureAccount(NostrIdentity.generate()) + + repeat(300) { index -> + processor.onGeohashMessage( + event = NostrEvent( + id = "cold-start-$index", + pubkey = index.toString(16).padStart(64, '0'), + createdAt = 1, + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", "u4pruy")), + content = "message-$index" + ), + geohash = "u4pruy", + accountEpoch = accountEpoch + ) + } + + withTimeout(5_000) { + while (AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().size < 300) { + kotlinx.coroutines.yield() + } + } + + assertEquals( + (0 until 300).map { "cold-start-$it" }.toSet(), + AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet() + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index 5953931e..d8225ba6 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -6,7 +6,6 @@ import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.DataManager -import com.bitchat.android.ui.MeshDelegateHandler import com.bitchat.android.ui.MessageManager import com.bitchat.android.ui.NoiseSessionDelegate import com.bitchat.android.ui.PrivateChatManager @@ -82,7 +81,7 @@ class NostrDirectMessageHandlerTest { application = application, state = state, privateChatManager = privateChatManager, - meshDelegateHandler = mock(), + updateDeliveryStatus = { _, _ -> }, scope = scope, repo = GeohashRepository(application, state, dataManager), dataManager = dataManager, @@ -153,7 +152,7 @@ class NostrDirectMessageHandlerTest { application = application, state = state, privateChatManager = privateChatManager, - meshDelegateHandler = mock(), + updateDeliveryStatus = { _, _ -> }, scope = scope, repo = GeohashRepository(application, state, dataManager), dataManager = dataManager, @@ -211,7 +210,7 @@ class NostrDirectMessageHandlerTest { application = application, state = state, privateChatManager = privateChatManager, - meshDelegateHandler = mock(), + updateDeliveryStatus = { _, _ -> }, scope = scope, repo = GeohashRepository(application, state, dataManager), dataManager = dataManager, diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt new file mode 100644 index 00000000..db6e5372 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -0,0 +1,129 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NostrPendingEventQueueTest { + @Test + fun `empty relay set is not queued`() { + val queue = NostrPendingEventQueue(capacity = 2) + + assertNull( + queue.enqueue( + event("empty"), + emptyList(), + liveLocationToken = null, + accountGeneration = 1L + ) + ) + assertEquals(0, queue.size()) + } + + @Test + fun `capacity evicts the oldest publish`() { + val queue = NostrPendingEventQueue(capacity = 2) + queue.enqueue(event("one"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("two"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("three"), listOf("relay"), null, accountGeneration = 1L) + + assertEquals( + listOf("two", "three"), + queue.pendingForRelay("relay", accountGeneration = 1L) + .map { it.event.content } + ) + } + + @Test + fun `duplicate event publishes retain independent delivery state`() { + val queue = NostrPendingEventQueue(capacity = 4) + val signedEvent = event("same") + val firstId = requireNotNull( + queue.enqueue( + signedEvent, + listOf("relay-a", "relay-b"), + liveLocationToken = null, + accountGeneration = 1L + ) + ) + val secondId = requireNotNull( + queue.enqueue( + signedEvent, + listOf("relay-a"), + liveLocationToken = null, + accountGeneration = 1L + ) + ) + assertNotEquals(firstId, secondId) + + queue.markDelivered(firstId, "relay-a") + + assertEquals( + listOf(secondId), + queue.pendingForRelay("relay-a", accountGeneration = 1L) + .map { it.queueId } + ) + assertEquals( + listOf(firstId), + queue.pendingForRelay("relay-b", accountGeneration = 1L) + .map { it.queueId } + ) + + queue.markDelivered(firstId, "relay-b") + assertEquals(1, queue.size()) + } + + @Test + fun `privacy purge retains non-live publishes`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("manual"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("live"), listOf("relay"), 42L, accountGeneration = 1L) + + queue.removeLiveLocationEvents(accountGeneration = 1L) + + assertEquals( + listOf("manual"), + queue.pendingForRelay("relay", accountGeneration = 1L) + .map { it.event.content } + ) + } + + @Test + fun `old account deliveries are excluded after generation changes`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("old"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("current"), listOf("relay"), null, accountGeneration = 2L) + + val deliveries = queue.pendingForRelay("relay", accountGeneration = 2L) + + assertEquals(listOf("current"), deliveries.map { it.event.content }) + assertEquals(listOf(2L), deliveries.map { it.accountGeneration }) + } + + @Test + fun `stale privacy purge cannot remove replacement account deliveries`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("old-live"), listOf("relay"), 11L, accountGeneration = 1L) + queue.enqueue(event("new-live"), listOf("relay"), 22L, accountGeneration = 2L) + + queue.removeLiveLocationEvents(accountGeneration = 1L) + + assertEquals( + listOf("new-live"), + queue.pendingForRelay("relay", accountGeneration = 2L) + .map { it.event.content } + ) + } + + private fun event(content: String): NostrEvent { + val privateKey = "0".repeat(63) + "1" + return NostrEvent( + pubkey = NostrCrypto.derivePublicKey(privateKey), + createdAt = 1, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), + content = content + ).sign(privateKey) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt index a51e109b..ac903a10 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -9,6 +9,34 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class NostrRelayManagerLifecycleSmokeTest { + @Test + fun `owner teardown is synchronous and preserves other subscription owners`() { + val manager = NostrRelayManager.shared + manager.disconnect() + manager.clearAllSubscriptions() + val filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)) + + manager.subscribe( + filter = filter, + id = "background-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = NostrRelayManager.OWNER_BACKGROUND + ) + manager.subscribe( + filter = filter, + id = "ui-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = "test-ui" + ) + + manager.unsubscribeOwner("test-ui") + + assertEquals(setOf("background-contract"), manager.getActiveSubscriptions().keys) + manager.clearAllSubscriptions() + } + @Test fun `disconnected manager maintains subscription and empty publish invariants locally`() { val manager = NostrRelayManager.shared @@ -28,7 +56,7 @@ class NostrRelayManagerLifecycleSmokeTest { assertEquals(1, manager.getActiveSubscriptionCount()) assertTrue(manager.getActiveSubscriptions().containsKey(id)) assertTrue(manager.validateSubscriptionConsistency().isConsistent) - manager.sendEvent(signedEvent(), relayUrls = emptyList()) + assertFalse(manager.sendEvent(signedEvent(), relayUrls = emptyList())) manager.retryConnection("wss://not-configured.example") manager.unsubscribe(id) @@ -48,7 +76,7 @@ class NostrRelayManagerLifecycleSmokeTest { val oldGeneration = manager.accountGenerationForTesting() val event = signedEvent() assertTrue(manager.registerPendingGiftWrap(event.id, oldGeneration)) - manager.sendEvent(event, relayUrls = emptyList()) + manager.sendEvent(event, relayUrls = targetRelays) assertEquals(1, manager.queuedEventCountForTesting()) assertEquals(1, manager.pendingGiftWrapCountForTesting()) @@ -73,11 +101,11 @@ class NostrRelayManagerLifecycleSmokeTest { val resetToken = manager.discardForAccountReset() val event = signedEvent() - assertFalse(manager.sendEvent(event, relayUrls = emptyList())) + assertFalse(manager.sendEvent(event, relayUrls = targetRelays)) assertEquals(0, manager.queuedEventCountForTesting()) assertTrue(manager.completeAccountReset(resetToken)) - assertTrue(manager.sendEvent(event, relayUrls = emptyList())) + assertTrue(manager.sendEvent(event, relayUrls = targetRelays)) assertEquals(1, manager.queuedEventCountForTesting()) val cleanupReset = manager.discardForAccountReset() @@ -93,12 +121,12 @@ class NostrRelayManagerLifecycleSmokeTest { assertFalse(manager.discardForAccountReset(firstReset)) assertFalse(manager.completeAccountReset(firstReset)) - assertFalse(manager.sendEvent(event, relayUrls = emptyList())) + assertFalse(manager.sendEvent(event, relayUrls = targetRelays)) assertEquals(0, manager.queuedEventCountForTesting()) assertTrue(manager.discardForAccountReset(secondReset)) assertTrue(manager.completeAccountReset(secondReset)) - assertTrue(manager.sendEvent(event, relayUrls = emptyList())) + assertTrue(manager.sendEvent(event, relayUrls = targetRelays)) val cleanupReset = manager.discardForAccountReset() assertTrue(manager.completeAccountReset(cleanupReset)) @@ -158,4 +186,6 @@ class NostrRelayManagerLifecycleSmokeTest { content = "local" ).sign(privateKey) } + + private val targetRelays = listOf(NostrRelayManager.defaultRelays().first()) } 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 1c04745e..62963f9e 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -79,6 +79,10 @@ class AppStateStoreTest { setOf("ble-1", "wifi-1", "shared"), AppStateStore.getDirectPeers() ) + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.directPeers.value + ) } @Test @@ -128,16 +132,29 @@ class AppStateStoreTest { } @Test - fun `canonicalized private chat history is chronological after alias merge`() { + fun `canonicalized private chat history keeps arrival order when peer clocks differ`() { val noiseKeyHex = "01".repeat(32) val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 1 }) - val later = BitchatMessage(id = "later", sender = "alice", content = "later", timestamp = Date(3)) - val earlier = BitchatMessage(id = "earlier", sender = "alice", content = "earlier", timestamp = Date(1)) + val firstArrival = BitchatMessage( + id = "first-arrival", + sender = "alice", + content = "sent from a clock that is ahead", + timestamp = Date(3) + ) + val secondArrival = BitchatMessage( + id = "second-arrival", + sender = "alice", + content = "sent from a clock that is behind", + timestamp = Date(1) + ) - AppStateStore.addPrivateMessage(contactID, later) - AppStateStore.addPrivateMessage(noiseKeyHex, earlier) + AppStateStore.addPrivateMessage(contactID, firstArrival) + AppStateStore.addPrivateMessage(noiseKeyHex, secondArrival) - assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID]) + assertEquals( + listOf(firstArrival, secondArrival), + AppStateStore.privateMessages.value[contactID] + ) } @Test diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt new file mode 100644 index 00000000..ce3eedbd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt @@ -0,0 +1,76 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.ChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class ConversationAliasResolverTest { + private lateinit var scope: CoroutineScope + private lateinit var state: ChatState + + @Before + fun setUp() { + AppStateStore.clear() + scope = CoroutineScope(SupervisorJob()) + state = ChatState(scope) + } + + @After + fun tearDown() { + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `explicit alias merge restores global order for interleaved arrivals`() { + val firstArrival = BitchatMessage( + id = "alias-a-first", + sender = "alice", + content = "first arrival through alias A", + timestamp = Date(300) + ) + val secondArrival = BitchatMessage( + id = "alias-b-first", + sender = "alice", + content = "second arrival through alias B", + timestamp = Date(100) + ) + val thirdArrival = BitchatMessage( + id = "alias-a-second", + sender = "alice", + content = "third arrival through alias A", + timestamp = Date(200) + ) + AppStateStore.addPrivateMessage("target-peer", firstArrival) + AppStateStore.addPrivateMessage("source-alias", secondArrival) + AppStateStore.addPrivateMessage("target-peer", thirdArrival) + state.setPrivateChats( + linkedMapOf( + "target-peer" to listOf(firstArrival, thirdArrival), + "source-alias" to listOf(secondArrival) + ) + ) + + ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = "target-peer", + keysToMerge = listOf("source-alias") + ) + + assertEquals( + listOf(firstArrival, secondArrival, thirdArrival), + state.getPrivateChatsValue()["target-peer"] + ) + assertEquals( + listOf(firstArrival, secondArrival, thirdArrival), + AppStateStore.privateMessages.value["target-peer"] + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 354fdc62..d8371690 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -69,6 +69,34 @@ class PrivateChatManagerTest { assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID]) } + @Test + fun `headless Nostr processing stores messages without retaining UI unread work`() { + val headlessManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock(), + trackUnreadMessages = false + ) + val message = BitchatMessage( + id = "background-nostr-message", + sender = "alice", + content = "background", + timestamp = Date(1), + isPrivate = true, + senderPeerID = "nostr_background" + ) + + headlessManager.handleIncomingPrivateMessage( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + + assertEquals(listOf(message), AppStateStore.privateMessages.value["nostr_background"]) + assertTrue(state.getUnreadPrivateMessagesValue().isEmpty()) + } + @Test fun `canonical conversation sends read receipt through live mesh peer id`() { val noiseKey = ByteArray(32) { 9 }