From a105d884cb515ba7ef76fbe266380900f7c69192 Mon Sep 17 00:00:00 2001
From: callebtc <93376500+callebtc@users.noreply.github.com>
Date: Wed, 29 Jul 2026 13:46:21 +0200
Subject: [PATCH] Polish persistent private conversations
---
app/src/main/AndroidManifest.xml | 5 +
.../android/features/file/FileUtils.kt | 26 +
.../identity/SecureIdentityStateManager.kt | 7 +
.../nostr/NostrDirectMessageHandler.kt | 33 +-
.../ConversationNotificationReceiver.kt | 84 ++
.../bitchat/android/services/AppStateStore.kt | 370 ++++++-
.../services/ConversationListPreferences.kt | 164 +++
.../services/ConversationRepository.kt | 965 ++++++++++++++++--
.../services/ConversationStorageCipher.kt | 98 ++
.../services/IncomingMessageAdmission.kt | 8 +-
.../services/PrivateMessageArrivalOrder.kt | 33 +-
.../java/com/bitchat/android/ui/ChatScreen.kt | 17 +-
.../com/bitchat/android/ui/ChatViewModel.kt | 274 ++++-
.../bitchat/android/ui/CommandProcessor.kt | 71 +-
.../bitchat/android/ui/ConversationSummary.kt | 53 +-
.../bitchat/android/ui/MediaSendingManager.kt | 22 +-
.../bitchat/android/ui/MeshPeerListSheet.kt | 914 +++++++++++++----
.../com/bitchat/android/ui/MessageManager.kt | 57 +-
.../bitchat/android/ui/NotificationManager.kt | 227 +++-
.../bitchat/android/ui/PrivateChatManager.kt | 89 ++
app/src/main/res/values/strings.xml | 47 +-
.../nostr/NostrDirectMessageHandlerTest.kt | 17 +
.../android/services/AppStateStoreTest.kt | 62 ++
.../services/ConversationDatabaseTest.kt | 337 +++++-
.../ConversationListPreferencesTest.kt | 75 ++
.../services/ConversationRepositoryTest.kt | 12 +-
.../InMemoryConversationStorageCipher.kt | 39 +
.../services/IncomingMessageAdmissionTest.kt | 145 +++
.../android/ui/ConversationSummaryTest.kt | 41 +
.../ui/MediaSendingManagerMigrationTest.kt | 33 +-
30 files changed, 3865 insertions(+), 460 deletions(-)
create mode 100644 app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt
create mode 100644 app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt
create mode 100644 app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt
create mode 100644 app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt
create mode 100644 app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0c71b94c..44740452 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -137,6 +137,11 @@
+
+
) {
+ val roots = listOf(context.filesDir, context.cacheDir)
+ .mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
+ paths.asSequence()
+ .mapNotNull { runCatching { File(it).canonicalFile }.getOrNull() }
+ .distinctBy(File::getPath)
+ .filter { file ->
+ roots.any { root ->
+ file.path == root.path ||
+ file.path.startsWith(root.path + File.separator)
+ }
+ }
+ .forEach { file ->
+ runCatching {
+ if (file.isFile && !file.delete()) {
+ Log.w(TAG, "Unable to delete unreferenced conversation media")
+ }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
index ab00b137..1efa87a2 100644
--- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
+++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
@@ -528,4 +528,11 @@ class SecureIdentityStateManager {
}
editor.apply()
}
+
+ /** Use for panic paths that must finish the disk mutation before identity reset continues. */
+ fun clearSecureValuesSynchronously(vararg keys: String): Boolean {
+ val editor = prefs.edit()
+ keys.forEach(editor::remove)
+ return editor.commit()
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
index 2fdc3180..fffcdab7 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
@@ -132,7 +132,14 @@ class NostrDirectMessageHandler(
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
- handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey)
+ val admitted = handleFavoriteControl(
+ favoriteControl,
+ conversationID,
+ senderNickname,
+ timestamp,
+ senderPubkey
+ )
+ if (!admitted) return
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
@@ -157,13 +164,14 @@ class NostrDirectMessageHandler(
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
- withContext(Dispatchers.Main) {
- privateChatManager.handleIncomingPrivateMessage(
+ val admitted = withContext(Dispatchers.Main) {
+ privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = suppressUnread,
origin = PrivateMessageOrigin.NOSTR
)
}
+ if (!admitted) return
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
@@ -215,13 +223,19 @@ class NostrDirectMessageHandler(
senderNostrPubkey = senderPubkey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
- withContext(Dispatchers.Main) {
- privateChatManager.handleIncomingPrivateMessage(
+ val admitted = withContext(Dispatchers.Main) {
+ privateChatManager.handleIncomingPrivateMessageDurably(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
}
+ if (!admitted) {
+ com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(
+ application,
+ listOf(savedPath)
+ )
+ }
} else {
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
}
@@ -238,15 +252,15 @@ class NostrDirectMessageHandler(
senderNickname: String,
timestamp: Date,
senderPubkey: String
- ) {
- try {
+ ): Boolean {
+ return try {
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
- return
+ return false
}
FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
@@ -278,7 +292,7 @@ class NostrDirectMessageHandler(
)
withContext(Dispatchers.Main) {
- privateChatManager.handleIncomingPrivateMessage(
+ privateChatManager.handleIncomingPrivateMessageDurably(
message = systemMessage,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
@@ -286,6 +300,7 @@ class NostrDirectMessageHandler(
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
+ false
}
}
diff --git a/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt
new file mode 100644
index 00000000..e6dbef2a
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt
@@ -0,0 +1,84 @@
+package com.bitchat.android.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.RemoteInput
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.DeliveryStatus
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.android.services.ContactDirectory
+import com.bitchat.android.services.MessageRouter
+import com.bitchat.android.ui.NotificationManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import java.util.Date
+import java.util.UUID
+
+/** Handles privacy-scoped direct reply and mark-read actions from DM notifications. */
+class ConversationNotificationReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val conversationID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID)
+ ?.let(ContactDirectory::canonicalConversationId)
+ ?: return
+ val pendingResult = goAsync()
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ try {
+ var acknowledged = false
+ when (intent.action) {
+ NotificationManager.ACTION_MARK_CONVERSATION_READ -> {
+ acknowledged =
+ AppStateStore.setPrivateConversationRead(conversationID, true)
+ }
+
+ NotificationManager.ACTION_REPLY_TO_CONVERSATION -> {
+ val reply = RemoteInput.getResultsFromIntent(intent)
+ ?.getCharSequence(NotificationManager.KEY_TEXT_REPLY)
+ ?.toString()
+ ?.trim()
+ ?.takeIf(String::isNotEmpty)
+ ?: return@launch
+ val mesh = MeshServiceHolder.getUnifiedOrCreate(
+ context.applicationContext
+ )
+ val message = BitchatMessage(
+ id = UUID.randomUUID().toString().uppercase(),
+ sender = mesh.myPeerID,
+ content = reply,
+ timestamp = Date(),
+ isPrivate = true,
+ recipientNickname = intent.getStringExtra(
+ NotificationManager.EXTRA_SENDER_NICKNAME
+ ),
+ senderPeerID = mesh.myPeerID,
+ deliveryStatus = DeliveryStatus.Sending
+ )
+ val persisted = AppStateStore.addPrivateMessageDurably(
+ peerID = conversationID,
+ msg = message,
+ forceRead = true
+ )
+ if (persisted) {
+ MessageRouter.getInstance(context.applicationContext, mesh)
+ .sendPrivate(
+ content = reply,
+ toPeerID = conversationID,
+ recipientNickname = message.recipientNickname.orEmpty(),
+ messageID = message.id
+ )
+ acknowledged =
+ AppStateStore.setPrivateConversationRead(conversationID, true)
+ }
+ }
+ }
+ if (acknowledged) {
+ NotificationManager.acknowledgeConversation(context, conversationID)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
index 5691e275..056e1524 100644
--- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
+++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow
object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf()
+ private val reservedPrivateMessageIds = mutableSetOf()
private val seenPublicMessageKeys = mutableSetOf()
private val peerIdsByTransport = mutableMapOf>()
private var privateWritesSinceGlobalPrune = 0
@@ -34,10 +35,14 @@ object AppStateStore {
val privateMessages: StateFlow