mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-11-01 07:51:15 +00:00
Merge remote-tracking branch 'origin/main' into codex/nostr-double-ratchet
# Conflicts: # app/src/main/java/com/bitchat/android/BitchatApplication.kt # app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt # app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt # app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt # app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt # app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt # app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt # app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
This commit is contained in:
commit
e19ed03755
@ -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) { }
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) { }
|
||||
|
||||
@ -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<String>) {
|
||||
|
||||
@ -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<RuntimePerformanceProfile> = _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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>, channel: String?) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String>()
|
||||
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}")
|
||||
}
|
||||
|
||||
@ -28,14 +28,17 @@ class GeohashRepository(
|
||||
// conversation key (e.g., "nostr_<pub16>") -> source geohash it belongs to
|
||||
private val conversationGeohash: MutableMap<String, String> = 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<String, Int>()
|
||||
@ -180,12 +193,15 @@ class GeohashRepository(
|
||||
state.setGeohashParticipantCounts(counts)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
||||
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getNostrKeyMapping(): Map<String, String> = 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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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<String, GeohashDmSubscription>()
|
||||
private val pendingGeohashDmSubscriptions = mutableSetOf<String>()
|
||||
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<String, GeohashDmSubscription> {
|
||||
val required = linkedMapOf<String, GeohashDmSubscription>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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<String>()
|
||||
@ -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")
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<String>,
|
||||
val liveLocationToken: Long?,
|
||||
val accountGeneration: Long
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val entries = ArrayDeque<Entry>()
|
||||
private var nextQueueId = 1L
|
||||
|
||||
fun enqueue(
|
||||
event: NostrEvent,
|
||||
relayUrls: Collection<String>,
|
||||
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<Delivery> = 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 }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ object AppStateStore {
|
||||
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
|
||||
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
private val _directPeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val directPeers: StateFlow<Set<String>> = _directPeers.asStateFlow()
|
||||
// Connected peer IDs (mesh ephemeral IDs)
|
||||
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
||||
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
||||
@ -29,6 +31,12 @@ object AppStateStore {
|
||||
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
|
||||
|
||||
private val _nickname = MutableStateFlow("")
|
||||
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||
|
||||
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
|
||||
|
||||
// Channel messages by channel name
|
||||
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _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<String>) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport[transportId] = ids.toSet()
|
||||
@ -69,20 +85,25 @@ object AppStateStore {
|
||||
fun setTransportDirectPeers(transportId: String, ids: Collection<String>) {
|
||||
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<String> {
|
||||
synchronized(this) {
|
||||
return directPeerIdsByTransport.values.flatten().toSet()
|
||||
}
|
||||
fun getDirectPeers(): Set<String> = _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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -102,9 +102,10 @@ object ContactDirectory {
|
||||
}
|
||||
|
||||
return merged.mapValues { (_, messages) ->
|
||||
messages
|
||||
.distinctBy { it.id }
|
||||
.sortedWith(compareBy<BitchatMessage> { 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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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<String, Long>()
|
||||
private var nextSequence = 0L
|
||||
|
||||
fun record(messageID: String) {
|
||||
synchronized(this) {
|
||||
if (messageID !in sequenceByMessageID) {
|
||||
sequenceByMessageID[messageID] = nextSequence++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String>) {
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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<String>() // Set of nostr pubkey hex
|
||||
val geohashBlockedUsers: Set<String> 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)
|
||||
}
|
||||
|
||||
@ -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<String>()
|
||||
private val samplingSubscriptionIds = mutableMapOf<String, String>()
|
||||
private val liveSamplingSubscriptionGeohashes = mutableSetOf<String>()
|
||||
private var requestedLiveSamplingGeohashes: Set<String> = emptySet()
|
||||
private var requestedUserSamplingGeohashes: Set<String> = 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
|
||||
)
|
||||
|
||||
@ -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) { }
|
||||
|
||||
33
app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt
Normal file
33
app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt
Normal file
@ -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)
|
||||
}
|
||||
@ -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})")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<String, String>,
|
||||
known: Map<String, String>,
|
||||
discovered: Set<String>
|
||||
) {
|
||||
_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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<String> =
|
||||
(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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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<Application>()
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<MeshDelegateHandler>(),
|
||||
updateDeliveryStatus = { _, _ -> },
|
||||
scope = scope,
|
||||
repo = GeohashRepository(application, state, dataManager),
|
||||
dataManager = dataManager,
|
||||
@ -153,7 +152,7 @@ class NostrDirectMessageHandlerTest {
|
||||
application = application,
|
||||
state = state,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = mock<MeshDelegateHandler>(),
|
||||
updateDeliveryStatus = { _, _ -> },
|
||||
scope = scope,
|
||||
repo = GeohashRepository(application, state, dataManager),
|
||||
dataManager = dataManager,
|
||||
@ -211,7 +210,7 @@ class NostrDirectMessageHandlerTest {
|
||||
application = application,
|
||||
state = state,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = mock<MeshDelegateHandler>(),
|
||||
updateDeliveryStatus = { _, _ -> },
|
||||
scope = scope,
|
||||
repo = GeohashRepository(application, state, dataManager),
|
||||
dataManager = dataManager,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user