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:
Dev 2026-07-28 02:29:44 +03:00
commit e19ed03755
48 changed files with 3365 additions and 1617 deletions

View File

@ -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) { }

View File

@ -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
}
/**

View File

@ -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)
}
}
}

View File

@ -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) { }

View File

@ -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>) {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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}")
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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")

View File

@ -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.

View File

@ -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 }
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
)
)
}

View File

@ -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()

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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 })
}
}

View File

@ -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

View File

@ -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 }

View File

@ -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
}
}
}

View File

@ -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>) {

View File

@ -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}")

View File

@ -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)
}

View File

@ -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
)

View File

@ -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) { }

View 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)
}

View File

@ -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})")

View File

@ -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 {

View File

@ -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 {

View File

@ -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()
}
}

View File

@ -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.
*/

View File

@ -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
)
}

View File

@ -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))
}
}

View File

@ -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()
)
}
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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())
}

View File

@ -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

View File

@ -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"]
)
}
}

View File

@ -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 }