mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 08:25:22 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user