Background persistence (#505)

* persistence step 1

* fix build

* messages in the background work, notifications not yet

* app state store

* DM icon shows up

* notification launches when app is closed!

* keep ui updated

* lifecycle fixes

* extensive logging, maybe revert later

* send nickname in announcement

* quit in notification

* setting in about sheet

* fix quit bitchat

* lifecycle fixes

* power mode based on background state

* stats for both direciotns

* fix graph persistence

* better counting

* count per device

* only compute when debug sheet is open? untested

* fix read receipts

* fix read receipts fully

* fix unread badge if messages have been read in focus

* foreground promotion fix

* fix app kill in notification

* adjust to new tor

* nice

* about sheet design
This commit is contained in:
callebtc
2025-12-13 16:43:39 +07:00
committed by GitHub
parent e96330e50b
commit 3f8c236a72
24 changed files with 2079 additions and 575 deletions
@@ -149,6 +149,7 @@ class BluetoothConnectionManager(
try {
isActive = true
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try {
@@ -179,6 +180,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Server started")
} else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
}
@@ -189,6 +191,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Client started")
} else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
}
@@ -214,6 +217,7 @@ class BluetoothConnectionManager(
isActive = false
connectionScope.launch {
Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers
clientManager.stop()
serverManager.stop()
@@ -230,6 +234,18 @@ class BluetoothConnectionManager(
Log.i(TAG, "All Bluetooth services stopped")
}
}
/**
* Indicates whether this instance can be safely reused for a future start.
* Returns false if its coroutine scope has been cancelled.
*/
fun isReusable(): Boolean {
val active = connectionScope.isActive
if (!active) {
Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
}
return active
}
/**
* Set app background state for power optimization
@@ -52,6 +52,12 @@ class BluetoothMeshService(private val context: Context) {
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
context.applicationContext,
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
com.bitchat.android.util.NotificationIntervalManager()
)
// Service state management
private var isActive = false
@@ -61,8 +67,11 @@ class BluetoothMeshService(private val context: Context) {
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
setupDelegates()
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
@@ -98,6 +107,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet)
}
}
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
/**
@@ -105,6 +115,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun startPeriodicDebugLogging() {
serviceScope.launch {
Log.d(TAG, "Starting periodic debug logging loop")
while (isActive) {
try {
delay(10000) // 10 seconds
@@ -116,6 +127,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
}
}
Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
}
}
@@ -124,6 +136,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch {
Log.d(TAG, "Starting periodic announce loop")
while (isActive) {
try {
delay(30000) // 30 seconds
@@ -132,6 +145,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
}
}
Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
}
}
@@ -139,6 +153,7 @@ class BluetoothMeshService(private val context: Context) {
* Setup delegate connections between components
*/
private fun setupDelegates() {
Log.d(TAG, "Setting up component delegates")
// Provide nickname resolver to BLE broadcaster for detailed logs
try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
@@ -146,6 +161,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) {
// Update process-wide state first
try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
@@ -165,6 +183,7 @@ class BluetoothMeshService(private val context: Context) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
// Send announcement and cached messages after key exchange
serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
delay(100)
sendAnnouncementToPeer(peerID)
@@ -352,7 +371,36 @@ class BluetoothMeshService(private val context: Context) {
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
// Always reflect into process-wide store so UI can hydrate after recreation
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message)
// If no UI delegate attached (app closed), show DM notification via service manager
if (delegate == null && message.isPrivate) {
try {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
serviceNotificationManager.setAppBackgroundState(true)
serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
}
} catch (_: Exception) { }
}
}
override fun onChannelLeave(channel: String, fromPeer: String) {
@@ -477,12 +525,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
// Log incoming for debug graphs (do not double-count anywhere else)
try {
val nick = getPeerNicknames()[peerID]
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packetType = packet.type.toString(),
fromPeerID = peerID,
fromNickname = nick,
fromDeviceAddress = device?.address
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready
serviceScope.launch {
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
delay(200)
sendBroadcastAnnounce()
}
@@ -497,6 +556,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr]
@@ -535,6 +595,11 @@ class BluetoothMeshService(private val context: Context) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
}
if (terminated) {
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
return
}
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
@@ -546,6 +611,7 @@ class BluetoothMeshService(private val context: Context) {
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs
gossipSyncManager.start()
Log.d(TAG, "GossipSyncManager started")
} else {
Log.e(TAG, "Failed to start Bluetooth services")
}
@@ -567,11 +633,14 @@ class BluetoothMeshService(private val context: Context) {
sendLeaveAnnouncement()
serviceScope.launch {
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
delay(200) // Give leave message time to send
// Stop all components
gossipSyncManager.stop()
Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices()
Log.d(TAG, "BluetoothConnectionManager stop requested")
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@@ -579,9 +648,24 @@ class BluetoothMeshService(private val context: Context) {
messageHandler.shutdown()
packetProcessor.shutdown()
// Mark this instance as terminated and cancel its scope so it won't be reused
terminated = true
serviceScope.cancel()
Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
}
}
/**
* Whether this instance can be safely reused. Returns false after stopServices() or if
* any critical internal scope has been cancelled.
*/
fun isReusable(): Boolean {
val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
if (!reusable) {
Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
}
return reusable
}
/**
* Send public message
@@ -801,7 +885,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
// Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try {
@@ -812,8 +896,15 @@ class BluetoothMeshService(private val context: Context) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch
}
try {
// Avoid duplicate read receipts: check persistent store first
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
if (seenStore?.hasRead(messageID) == true) {
Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
return@launch
}
// Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -839,7 +930,10 @@ class BluetoothMeshService(private val context: Context) {
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
// Persist as read after successful send
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
}
@@ -852,7 +946,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce")
serviceScope.launch {
val nickname = delegate?.getNickname() ?: myPeerID
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -901,7 +995,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return
val nickname = delegate?.getNickname() ?: myPeerID
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -1000,6 +1094,13 @@ class BluetoothMeshService(private val context: Context) {
return peerManager.getFingerprintForPeer(peerID)
}
/**
* Get current active peer count (for status/notifications)
*/
fun getActivePeerCount(): Int {
return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
}
/**
* Get peer info for verification purposes
*/
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
val isRelay = (incomingAddr != null || incomingPeer != null)
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
// Always log outgoing for the actual transmission target
manager.logOutgoing(
packetType = typeName,
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
previousHopPeerID = incomingPeer
)
// Keep the verbose relay message for human readability
manager.logPacketRelayDetailed(
packetType = typeName,
senderPeerID = senderPeerID,
senderNickname = senderNick,
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
isRelay = isRelay
isRelay = true
)
} catch (_: Exception) {
// Silently ignore debug logging failures
@@ -224,26 +224,29 @@ class PowerManager(private val context: Context) {
}
private fun updatePowerMode() {
val newMode = when {
// Always use performance mode when charging (unless in background too long)
// Determine the base mode from battery/charging state only
val baseMode = when {
// Charging in foreground may use performance
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
// Critical battery - use ultra low power
// Critical battery - force ultra low power regardless of foreground/background
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
// Low battery - use power saver
// Low battery - prefer power saver
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
// Background app with medium battery - use power saver
isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
// Background app with good battery - use balanced
isAppInBackground -> PowerMode.BALANCED
// Foreground with good battery - use balanced
// 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