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