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