From 7fad2f220291257449d987df4ce669b316fa8af1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:41:16 +0200 Subject: [PATCH 1/5] persist --- app/proguard-rules.pro | 7 + .../com/bitchat/android/BitchatApplication.kt | 7 + .../android/features/file/FileUtils.kt | 52 +++ .../android/service/AppShutdownCoordinator.kt | 7 + .../bitchat/android/services/AppStateStore.kt | 314 ++++++++++++- .../services/PrivateMessageArrivalOrder.kt | 16 + .../android/services/SeenMessageStore.kt | 12 + .../com/bitchat/android/ui/ChatViewModel.kt | 94 +++- .../bitchat/android/ui/CommandProcessor.kt | 3 + .../bitchat/android/ui/MeshPeerListSheet.kt | 422 ++++++++++++++---- .../com/bitchat/android/ui/MessageManager.kt | 47 +- app/src/main/res/values/strings.xml | 10 + .../android/services/AppStateStoreTest.kt | 50 +++ 13 files changed, 919 insertions(+), 122 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index df941012..4363120a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,6 +17,13 @@ -keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.identity.** { *; } +# Room loads generated database implementations by name and invokes their no-argument +# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's +# constructor, causing AndroidX Startup to crash before Application.onCreate. +-keepclassmembers class * extends androidx.room.RoomDatabase { + (); +} + # Keep Tor implementation (always included) -keep class com.bitchat.android.net.RealTorProvider { *; } diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index ecd32327..fe3d924f 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -33,6 +33,13 @@ class BitchatApplication : Application() { com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) } catch (_: Exception) { } + // Restore private conversations before background transports can deliver new messages. + // AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace + // newer transport state with an older database snapshot. + try { + com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this) + } catch (_: Exception) { } + // Warm up Nostr identity to ensure npub is available for favorite notifications try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt index 10765416..138c665f 100644 --- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -5,6 +5,8 @@ import android.net.Uri import android.os.Environment import android.util.Log import androidx.core.content.FileProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType import java.io.File import java.io.FileOutputStream import java.io.InputStream @@ -323,4 +325,54 @@ object FileUtils { Log.e(TAG, "Failed to clear media files", e) } } + + /** + * Delete app-owned media referenced only by the conversation being removed. + * + * Canonical-path checks prevent message content from turning this into an arbitrary-file + * deletion primitive. Shared paths remain intact while any retained message still references + * them. + */ + fun deleteConversationMedia( + context: Context, + deletedMessages: Collection, + retainedMessages: Collection + ) { + val mediaTypes = setOf( + BitchatMessageType.Audio, + BitchatMessageType.Image, + BitchatMessageType.File + ) + val roots = listOf(context.filesDir, context.cacheDir) + .mapNotNull { runCatching { it.canonicalFile }.getOrNull() } + val retainedPaths = retainedMessages + .asSequence() + .filter { it.type in mediaTypes } + .mapNotNull { message -> + runCatching { File(message.content.trim()).canonicalPath }.getOrNull() + } + .toSet() + + deletedMessages + .asSequence() + .filter { it.type in mediaTypes } + .mapNotNull { message -> + runCatching { File(message.content.trim()).canonicalFile }.getOrNull() + } + .distinctBy(File::getPath) + .filter { file -> + file.path !in retainedPaths && + 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 conversation media") + } + } + } + } } diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index 30a641d0..cdef409f 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -64,6 +64,12 @@ object AppShutdownCoordinator { val torStop = async { try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } } + val conversationFlush = async { + try { + com.bitchat.android.services.AppStateStore + .awaitConversationPersistence() + } catch (_: Exception) { } + } // Clear AppState in-memory store try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } @@ -75,6 +81,7 @@ object AppShutdownCoordinator { // Wait up to 5 seconds for shutdown tasks withTimeoutOrNull(5000) { try { torStop.await() } catch (_: Exception) { } + try { conversationFlush.await() } catch (_: Exception) { } delay(100) } 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 e484d21a..6c78dec4 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -1,5 +1,6 @@ package com.bitchat.android.services +import android.content.Context import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.flow.MutableStateFlow @@ -15,6 +16,7 @@ object AppStateStore { private val seenMessageIds = mutableSetOf() private val seenPublicMessageKeys = mutableSetOf() private val peerIdsByTransport = mutableMapOf>() + private var privateWritesSinceGlobalPrune = 0 // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. private val directPeerIdsByTransport = mutableMapOf>() private val _directPeers = MutableStateFlow>(emptySet()) @@ -30,6 +32,11 @@ object AppStateStore { // Private messages by peerID private val _privateMessages = MutableStateFlow>>(emptyMap()) val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + private val _readPrivateMessageIDs = MutableStateFlow>(emptySet()) + val readPrivateMessageIDs: StateFlow> = _readPrivateMessageIDs.asStateFlow() + + @Volatile + private var conversationRepository: ConversationRepository? = null private val _nickname = MutableStateFlow("") val nickname: StateFlow = _nickname.asStateFlow() @@ -55,6 +62,26 @@ object AppStateStore { _selectedPrivateChatPeer.value = peerID } + fun initializeConversationPersistence(context: Context) { + val repository = ConversationRepository.getInstance(context.applicationContext) + conversationRepository = repository + repository.initialize(::restorePrivateConversations) + } + + /** + * Restores database state again for a newly created UI, even if Android reused this process + * after a controlled shutdown cleared the process-wide state. + */ + fun reloadConversationPersistence(context: Context) { + val repository = ConversationRepository.getInstance(context.applicationContext) + conversationRepository = repository + repository.reload(::restorePrivateConversations) + } + + suspend fun awaitConversationPersistence() { + conversationRepository?.awaitPendingWrites() + } + fun setTransportPeers(transportId: String, ids: List) { synchronized(this) { peerIdsByTransport[transportId] = ids.toSet() @@ -116,17 +143,51 @@ object AppStateStore { } } - fun addPrivateMessage(peerID: String, msg: BitchatMessage) { - synchronized(this) { - if (seenMessageIds.contains(msg.id)) return - seenMessageIds.add(msg.id) - PrivateMessageArrivalOrder.record(msg.id) - val conversationID = ContactDirectory.canonicalConversationId(peerID) - val map = _privateMessages.value.toMutableMap() - val list = (map[conversationID] ?: emptyList()) + msg - map[conversationID] = list - _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + fun addPrivateMessage( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean = false + ): Boolean = synchronized(this) { + if (seenMessageIds.contains(msg.id)) return@synchronized false + seenMessageIds.add(msg.id) + PrivateMessageArrivalOrder.record(msg.id) + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val map = _privateMessages.value.toMutableMap() + val list = (map[conversationID] ?: emptyList()) + msg + map[conversationID] = list + _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + + val isRead = forceRead || + msg.sender == "system" || + msg.sender == _nickname.value || + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(conversationID, ignoreCase = true) == true + if (isRead) { + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id } + val aliases = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.aliasesForConversation(conversationID) + + listOfNotNull(msg.senderPeerID) + }.getOrDefault(setOf(peerID, conversationID)) + val displayName = ContactDirectory.resolve(conversationID).displayName + ?: msg.sender.takeUnless { + it.isBlank() || it == "system" || it == _nickname.value + } + conversationRepository?.upsertMessage( + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + message = msg, + isRead = isRead + ) + prunePrivateMessagesLocked(conversationID) + true + } + + fun hasSeenMessage(messageID: String): Boolean = synchronized(this) { + messageID in seenMessageIds } private fun statusPriority(status: DeliveryStatus?): Int = when (status) { @@ -158,6 +219,7 @@ object AppStateStore { } if (changed) { _privateMessages.value = map + conversationRepository?.updateDeliveryStatus(messageID, status) } } } @@ -166,6 +228,14 @@ object AppStateStore { if (keysToMerge.isEmpty()) return synchronized(this) { val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID) + val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID) + .flatMap { key -> + runCatching { + ContactDirectory.aliasesForConversation(key).toList() + }.getOrDefault(listOf(key)) + } + .toSet() + conversationRepository?.mergeAliases(targetConversationID, persistenceAliases) val map = _privateMessages.value.toMutableMap() val targetList = (map[targetConversationID] ?: emptyList()).toMutableList() val targetIds = targetList.map { it.id }.toMutableSet() @@ -213,6 +283,85 @@ object AppStateStore { } } + fun markPrivateMessageRead(messageID: String) { + synchronized(this) { + if (messageID in _readPrivateMessageIDs.value) return + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID + conversationRepository?.markRead(messageID) + } + } + + fun isPrivateMessageRead(messageID: String): Boolean = + messageID in _readPrivateMessageIDs.value + + fun deletePrivateConversation(peerOrConversationID: String): Set { + synchronized(this) { + val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) + val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + val aliases = (matchingKeys + peerOrConversationID + canonicalID) + .flatMap { key -> + runCatching { + ContactDirectory.aliasesForConversation(key).toList() + }.getOrDefault(listOf(key)) + } + .toSet() + val messageIDs = matchingKeys + .flatMapTo(linkedSetOf()) { _privateMessages.value[it].orEmpty().map { it.id } } + + // Queue the database deletion while holding the same lock used by addPrivateMessage. + // A genuinely new arrival is therefore queued after the delete and starts a fresh chat. + conversationRepository?.deleteConversation(canonicalID, aliases) + + val updated = _privateMessages.value.toMutableMap() + matchingKeys.forEach(updated::remove) + _privateMessages.value = updated + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs + if ( + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(canonicalID, ignoreCase = true) == true + ) { + _selectedPrivateChatPeer.value = null + } + return messageIDs + } + } + + fun removePrivateMessage(messageID: String) { + synchronized(this) { + val updated = _privateMessages.value.toMutableMap() + var changed = false + updated.keys.toList().forEach { conversationID -> + val messages = updated[conversationID].orEmpty() + if (messages.any { it.id == messageID }) { + val remaining = messages.filterNot { it.id == messageID } + if (remaining.isEmpty()) { + updated.remove(conversationID) + } else { + updated[conversationID] = remaining + } + changed = true + } + } + if (!changed) return + conversationRepository?.deleteMessage(messageID) + _privateMessages.value = updated + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID + } + } + + fun clearPersistedPrivateConversations() { + synchronized(this) { + conversationRepository?.clearAll() + _privateMessages.value = emptyMap() + _readPrivateMessageIDs.value = emptySet() + _selectedPrivateChatPeer.value = null + } + } + fun addChannelMessage(channel: String, msg: BitchatMessage) { synchronized(this) { if (seenMessageIds.contains(msg.id)) return @@ -230,12 +379,14 @@ object AppStateStore { seenMessageIds.clear() seenPublicMessageKeys.clear() PrivateMessageArrivalOrder.clear() + privateWritesSinceGlobalPrune = 0 peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() _directPeers.value = emptySet() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() + _readPrivateMessageIDs.value = emptySet() _channelMessages.value = emptyMap() _nickname.value = "" _selectedPrivateChatPeer.value = null @@ -252,4 +403,147 @@ object AppStateStore { msg.content ).joinToString("\u001F") } + + private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) { + synchronized(this) { + val liveChats = _privateMessages.value + val liveMessageIDs = liveChats.values.flatten().map { it.id } + PrivateMessageArrivalOrder.restore(snapshot.arrivalOrder, liveMessageIDs) + + val merged = linkedMapOf>() + snapshot.chats.forEach { (conversationID, messages) -> + merged.getOrPut(conversationID) { mutableListOf() }.addAll(messages) + } + liveChats.forEach { (conversationID, messages) -> + val target = merged.getOrPut(conversationID) { mutableListOf() } + messages + .filterNot { it.id in snapshot.deletedMessageIDs } + .forEach { live -> + val existingIndex = target.indexOfFirst { it.id == live.id } + if (existingIndex >= 0) { + target[existingIndex] = live + } else { + target.add(live) + } + } + } + + merged.values.flatten().forEach { seenMessageIds.add(it.id) } + seenMessageIds.addAll(snapshot.deletedMessageIDs) + _readPrivateMessageIDs.value = + (snapshot.readMessageIDs + _readPrivateMessageIDs.value) - + snapshot.deletedMessageIDs + _privateMessages.value = ContactDirectory.canonicalizePrivateChats( + merged.mapValues { (_, messages) -> + PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) + } + ) + } + } + + private fun prunePrivateMessagesLocked(recentConversationID: String) { + val chats = _privateMessages.value.toMutableMap() + val removedIDs = linkedSetOf() + val recentKey = chats.keys.firstOrNull { key -> + ContactDirectory.canonicalConversationId(key) + .equals(recentConversationID, ignoreCase = true) + } + if (recentKey != null) { + val messages = chats[recentKey].orEmpty() + val excess = + messages.size - ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION + if (excess > 0) { + val removable = messages + .dropLast(1) + .sortedWith(privateMessagePruneComparator()) + .take(excess) + .mapTo(linkedSetOf()) { it.id } + removedIDs.addAll(removable) + chats[recentKey] = messages.filterNot { it.id in removable } + } + } + + privateWritesSinceGlobalPrune += 1 + if (privateWritesSinceGlobalPrune >= 64) { + privateWritesSinceGlobalPrune = 0 + var totalMessages = chats.values.sumOf { it.size } + var totalPayloadBytes = chats.values + .asSequence() + .flatten() + .sumOf(::privateMessagePayloadBytes) + if ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) { + val candidates = chats.values + .asSequence() + .flatMap { messages -> messages.dropLast(1).asSequence() } + .sortedWith(privateMessagePruneComparator()) + .iterator() + while ( + candidates.hasNext() && + ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) + ) { + val candidate = candidates.next() + if (!removedIDs.add(candidate.id)) continue + totalMessages -= 1 + totalPayloadBytes -= privateMessagePayloadBytes(candidate) + } + if ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) { + // Enforce the hard bound even when every conversation contains only its + // newest message. Read conversations still sort ahead of unread ones. + val latestCandidates = chats.values + .asSequence() + .flatten() + .filterNot { it.id in removedIDs } + .sortedWith(privateMessagePruneComparator()) + .iterator() + while ( + latestCandidates.hasNext() && + ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) + ) { + val candidate = latestCandidates.next() + if (!removedIDs.add(candidate.id)) continue + totalMessages -= 1 + totalPayloadBytes -= privateMessagePayloadBytes(candidate) + } + } + if (removedIDs.isNotEmpty()) { + chats.keys.toList().forEach { key -> + chats[key] = chats[key].orEmpty().filterNot { + it.id in removedIDs + } + } + } + } + } + + if (removedIDs.isNotEmpty()) { + _privateMessages.value = chats.filterValues { it.isNotEmpty() } + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - removedIDs + } + } + + private fun privateMessagePruneComparator(): Comparator = + compareByDescending { + it.id in _readPrivateMessageIDs.value + }.thenBy { + PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MAX_VALUE + } + + private fun privateMessagePayloadBytes(message: BitchatMessage): Long = + message.content.toByteArray(Charsets.UTF_8).size.toLong() + + (message.encryptedContent?.size ?: 0) + + message.mentions.orEmpty().sumOf { + it.toByteArray(Charsets.UTF_8).size + } } diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt index a5c92453..f5cc92ed 100644 --- a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt +++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt @@ -21,6 +21,22 @@ internal object PrivateMessageArrivalOrder { } } + fun restore(persistedOrder: List, liveMessageIDs: List) { + synchronized(this) { + sequenceByMessageID.clear() + nextSequence = 0L + (persistedOrder + liveMessageIDs).forEach { messageID -> + if (messageID !in sequenceByMessageID) { + sequenceByMessageID[messageID] = nextSequence++ + } + } + } + } + + fun sequenceOf(messageID: String): Long? = synchronized(this) { + sequenceByMessageID[messageID] + } + fun order(messages: List): List { synchronized(this) { if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) { diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index 3b531846..0701645a 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -1,5 +1,6 @@ package com.bitchat.android.services +import android.annotation.SuppressLint import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager @@ -20,6 +21,8 @@ class SeenMessageStore private constructor(private val context: Context) { private const val STORAGE_KEY = "seen_message_store_v1" private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS + // The constructor always receives applicationContext, so process lifetime is intentional. + @SuppressLint("StaticFieldLeak") @Volatile private var INSTANCE: SeenMessageStore? = null fun getInstance(appContext: Context): SeenMessageStore { return INSTANCE ?: synchronized(this) { @@ -54,6 +57,7 @@ class SeenMessageStore private constructor(private val context: Context) { locallyRead.add(id) trim(locallyRead) } + AppStateStore.markPrivateMessageRead(id) persist() } @@ -65,6 +69,14 @@ class SeenMessageStore private constructor(private val context: Context) { persist() } + @Synchronized fun remove(ids: Set) { + if (ids.isEmpty()) return + delivered.removeAll(ids) + locallyRead.removeAll(ids) + readReceiptsSent.removeAll(ids) + persist() + } + @Synchronized fun clear() { delivered.clear() locallyRead.clear() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index ba5775a0..770b6151 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -193,20 +193,29 @@ class ChatViewModel( val privateChats: StateFlow>> = state.privateChats val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages - internal val unreadConversations: StateFlow> = combine( + internal val conversations: StateFlow> = combine( state.unreadPrivateMessages, state.privateChats, state.nickname, state.connectedPeers ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs -> val seenStore = seenMessageStore - val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() } - buildUnreadConversationSummaries( + val connectedIdentitiesByPeer = connectedPeerIDs.associateWith { peerID -> + runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.canonicalConversationId(peerID) + }.getOrDefault(setOf(peerID)) + .mapTo(mutableSetOf()) { it.lowercase() } + } + buildConversationSummaries( unreadConversationIDs = unreadConversationIDs, privateChats = chats, currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID), canonicalize = ContactDirectory::canonicalConversationId, - isMessageRead = { message -> seenStore.hasBeenReadLocally(message.id) } + isMessageRead = { message -> + com.bitchat.android.services.AppStateStore.isPrivateMessageRead(message.id) || + seenStore.hasBeenReadLocally(message.id) + } ).map { summary -> val resolution = ContactDirectory.resolve(summary.conversationID) val resolvedNostrPubkey = summary.nostrPubkey @@ -221,6 +230,11 @@ class ChatViewModel( ?.let(ContactIdentityResolver::nostrAliasForPubkey) ?.let(::add) }.mapTo(mutableSetOf()) { it.lowercase() } + val connectedPeerID = connectedIdentitiesByPeer.entries + .firstOrNull { (_, connectedAliases) -> + connectedAliases.any(aliases::contains) + } + ?.key summary.copy( displayName = resolution.displayName @@ -230,13 +244,14 @@ class ChatViewModel( ?: summary.displayName, nostrPubkey = resolvedNostrPubkey, identityAliases = aliases, - isConnected = aliases.any(connectedPeerIDSet::contains), + isConnected = connectedPeerID != null, + connectedPeerID = connectedPeerID, sourceGeohash = aliases .asSequence() .mapNotNull(GeohashConversationRegistry::get) .firstOrNull() ) - } + }.let(::sortConversationSummaries) } .flowOn(Dispatchers.IO) .stateIn( @@ -294,6 +309,12 @@ class ChatViewModel( loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() + // Application startup performs the initial restore. Repeat it for every new UI owner + // because a quick reopen can reuse a process whose in-memory state was cleared during + // controlled shutdown. + com.bitchat.android.services.AppStateStore.reloadConversationPersistence( + getApplication() + ) // Mark queued private messages as failed when the router gives up on them try { com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID -> @@ -327,6 +348,8 @@ class ChatViewModel( messages.any { message -> message.sender != myNick && message.sender != "system" && + !com.bitchat.android.services.AppStateStore + .isPrivateMessageRead(message.id) && !seenMessageStore.hasBeenReadLocally(message.id) } } @@ -445,7 +468,6 @@ class ChatViewModel( override fun onCleared() { geohashViewModel.shutdownUiSubscriptions() com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) - super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } @@ -518,6 +540,62 @@ class ChatViewModel( hidePrivateChatSheet() } + fun deletePrivateConversation(peerOrConversationID: String) { + val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) + val deletedMessages = state.getPrivateChatsValue() + .filterKeys { key -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + .values + .flatten() + val unreadAliases = matchingUnreadAliases( + unreadConversationIDs = state.getUnreadPrivateMessagesValue(), + canonicalConversationID = canonicalID, + canonicalize = ContactDirectory::canonicalConversationId + ) + val deletedMessageIDs = + com.bitchat.android.services.AppStateStore.deletePrivateConversation(canonicalID) + val retainedMessages = + com.bitchat.android.services.AppStateStore.privateMessages.value.values.flatten() + viewModelScope.launch(Dispatchers.IO) { + com.bitchat.android.features.file.FileUtils.deleteConversationMedia( + context = getApplication(), + deletedMessages = deletedMessages, + retainedMessages = retainedMessages + ) + } + + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) + state.setUnreadPrivateMessages( + state.getUnreadPrivateMessagesValue() - unreadAliases + ) + seenMessageStore.remove(deletedMessageIDs) + + val selected = state.getSelectedPrivateChatPeerValue() + if ( + selected != null && + ContactDirectory.canonicalConversationId(selected) + .equals(canonicalID, ignoreCase = true) + ) { + privateChatManager.endPrivateChat() + setCurrentPrivateChatPeer(null) + } + val sheetPeer = state.getPrivateChatSheetPeerValue() + if ( + sheetPeer != null && + ContactDirectory.canonicalConversationId(sheetPeer) + .equals(canonicalID, ignoreCase = true) + ) { + hidePrivateChatSheet() + } + clearNotificationsForSender(canonicalID) + } + // MARK: - Open Latest Unread Private Chat fun openLatestUnreadPrivateChat() { @@ -1055,6 +1133,8 @@ class ChatViewModel( mediaSendingManager.clearPendingPrivateMediaConsent() // Clear all UI managers + com.bitchat.android.services.AppStateStore.clearPersistedPrivateConversations() + com.bitchat.android.services.AppStateStore.clear() messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 34b205af..5a42f82b 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -189,6 +189,9 @@ class CommandProcessor( // Clear private chat val peerID = state.getSelectedPrivateChatPeerValue()!! messageManager.clearPrivateMessages(peerID) + // `/clear` removes history but should not navigate away from the chat the + // command was issued in. A later message will repopulate this conversation. + state.setSelectedPrivateChatPeer(peerID) } state.getCurrentChannelValue() != null -> { // Clear channel messages diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 27bb7448..8ea2254c 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R +import android.text.format.DateUtils import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -32,9 +33,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -50,6 +57,7 @@ import com.bitchat.android.favorites.FavoriteRelationship import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.geohash.ChannelID import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette @@ -88,16 +96,32 @@ fun MeshPeerListSheet( val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle() + val conversations by viewModel.conversations.collectAsStateWithLifecycle() + val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle() val geohashPeopleCount = geohashPeople.size val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } - val unreadIdentityAliases = remember(unreadConversations) { - unreadConversations + val directPeerIdentityIDs = remember(peerDirect) { + peerDirect + .asSequence() + .filter { (_, isDirect) -> isDirect } + .mapTo(mutableSetOf()) { (peerID, _) -> peerID.lowercase() } + } + val wifiAwareIdentityIDs = remember(wifiAwarePeerIDs) { + wifiAwarePeerIDs.mapTo(mutableSetOf()) { it.lowercase() } + } + val conversationIdentityAliases = remember(conversations) { + conversations .flatMapTo(mutableSetOf()) { it.identityAliases } } val visibleConnectedPeers = connectedPeers.filterNot { peerID -> - peerID.lowercase() in unreadIdentityAliases + val aliases = runCatching { + ContactDirectory.aliasesForConversation(peerID) + }.getOrDefault(setOf(peerID)) + aliases.any { it.lowercase() in conversationIdentityAliases } + } + var pendingConversationDelete by remember { + mutableStateOf(null) } // Bottom sheet state @@ -134,15 +158,20 @@ fun MeshPeerListSheet( else -> visibleConnectedPeers.count { it != viewModel.myPeerID } } - if (unreadConversations.isNotEmpty()) { - item(key = "unread_private_messages_section") { - UnreadDirectMessagesSection( - conversations = unreadConversations, + if (conversations.isNotEmpty()) { + item(key = "private_conversations_section") { + DirectMessagesSection( + conversations = conversations, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, viewModel = viewModel, onPrivateChatStart = { conversationID -> viewModel.showPrivateChatSheet(conversationID) onDismiss() }, + onDeleteRequested = { conversation -> + pendingConversationDelete = conversation + }, modifier = Modifier.padding(top = 8.dp) ) } @@ -156,7 +185,7 @@ fun MeshPeerListSheet( iconRes = R.drawable.ic_spec_chat_bubbles, title = stringResource(R.string.channels), modifier = Modifier.padding( - top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp + top = if (conversations.isNotEmpty()) 20.dp else 8.dp ) ) Surface( @@ -209,11 +238,11 @@ fun MeshPeerListSheet( GeohashPeopleList( viewModel = viewModel, onTapPerson = onDismiss, - excludedIdentityAliases = unreadIdentityAliases, + excludedIdentityAliases = conversationIdentityAliases, modifier = Modifier.padding( top = if ( joinedChannels.isNotEmpty() || - unreadConversations.isNotEmpty() + conversations.isNotEmpty() ) 20.dp else 8.dp ) ) @@ -224,7 +253,7 @@ fun MeshPeerListSheet( modifier = Modifier.padding( top = if ( joinedChannels.isNotEmpty() || - unreadConversations.isNotEmpty() + conversations.isNotEmpty() ) 20.dp else 8.dp ), connectedPeers = visibleConnectedPeers, @@ -235,7 +264,7 @@ fun MeshPeerListSheet( selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, peopleCount = peopleCount, - excludedIdentityAliases = unreadIdentityAliases, + excludedIdentityAliases = conversationIdentityAliases, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -273,6 +302,54 @@ fun MeshPeerListSheet( } } + pendingConversationDelete?.let { conversation -> + AlertDialog( + onDismissRequest = { pendingConversationDelete = null }, + icon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null + ) + }, + title = { + Text( + text = stringResource(R.string.delete_conversation_title), + fontFamily = BitchatFontFamily + ) + }, + text = { + Text( + text = stringResource( + R.string.delete_conversation_message, + conversation.displayName + ), + fontFamily = BitchatFontFamily + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.deletePrivateConversation(conversation.conversationID) + pendingConversationDelete = null + } + ) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + fontFamily = BitchatFontFamily + ) + } + }, + dismissButton = { + TextButton(onClick = { pendingConversationDelete = null }) { + Text( + text = stringResource(android.R.string.cancel), + fontFamily = BitchatFontFamily + ) + } + } + ) + } } } @@ -612,23 +689,29 @@ fun PeopleSection( } } } + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun UnreadDirectMessagesSection( - conversations: List, +private fun DirectMessagesSection( + conversations: List, + directPeerIdentityIDs: Set, + wifiAwareIdentityIDs: Set, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit, + onDeleteRequested: (ConversationSummary) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme + val hapticFeedback = LocalHapticFeedback.current + val deleteDescription = stringResource(R.string.delete_conversation_action) Column(modifier = modifier) { SheetIconSectionHeader( iconRes = R.drawable.ic_spec_envelope, - title = stringResource(R.string.cd_unread_private_messages) + title = stringResource(R.string.conversations) ) Surface( @@ -646,87 +729,63 @@ private fun UnreadDirectMessagesSection( Column { if (index > 0) SheetCardDivider() - val subtitle = when { - conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}" - conversation.transport == DirectMessageTransport.NOSTR -> - stringResource(R.string.cd_reachable_via_nostr) - !conversation.isConnected -> - stringResource(R.string.cd_offline_mesh_chat) - else -> null + val dismissState = rememberSwipeToDismissBoxState() + LaunchedEffect(dismissState.currentValue, conversation.conversationID) { + if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onDeleteRequested(conversation) + dismissState.reset() + } } - val peerIdentity = conversation.nostrPubkey - ?.let(viewModel::peerIdentityForNostrPubkey) - ?: viewModel.peerIdentityForMeshPeer(conversation.conversationID) - val assignedColor = colorForPeer(peerIdentity, palette) - val (baseNameRaw, suffix) = splitSuffix(conversation.displayName) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onPrivateChatStart(conversation.conversationID) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = true, + enableDismissFromEndToStart = true, + backgroundContent = { + val alignment = when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart + else -> Alignment.CenterEnd } - .padding( - horizontal = SheetRowHorizontal, - vertical = SheetRowVertical - ), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier.size(SheetRowLeadingSlot), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_envelope), - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentOrange - ) - } - - Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) - - Column(modifier = Modifier.weight(1f)) { Row( + modifier = Modifier + .fillMaxSize() + .background(colorScheme.errorContainer) + .padding(horizontal = SheetRowHorizontal), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = truncateNickname(baseNameRaw), - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = assignedColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (suffix.isNotEmpty()) { - Text( - text = suffix, - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = assignedColor.copy(alpha = SUFFIX_ALPHA) - ) + horizontalArrangement = when (alignment) { + Alignment.CenterStart -> Arrangement.Start + else -> Arrangement.End } - } - - if (subtitle != null) { + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = deleteDescription, + tint = colorScheme.onErrorContainer, + modifier = Modifier.size(PeerRowIconSize) + ) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = subtitle, + text = stringResource(R.string.delete), fontFamily = BitchatFontFamily, - fontSize = 11.sp, - color = palette.textTertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onErrorContainer ) } } - - UnreadBadge( - count = conversation.unreadCount, - colorScheme = colorScheme + ) { + ConversationRow( + conversation = conversation, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, + viewModel = viewModel, + deleteDescription = deleteDescription, + onClick = { + onPrivateChatStart(conversation.conversationID) + }, + onDeleteRequested = { + onDeleteRequested(conversation) + } ) } } @@ -735,6 +794,171 @@ private fun UnreadDirectMessagesSection( } } +@Composable +private fun ConversationRow( + conversation: ConversationSummary, + directPeerIdentityIDs: Set, + wifiAwareIdentityIDs: Set, + viewModel: ChatViewModel, + deleteDescription: String, + onClick: () -> Unit, + onDeleteRequested: () -> Unit +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val liveIdentityIDs = conversation.identityAliases + + listOfNotNull(conversation.connectedPeerID?.lowercase()) + val isWifiAware = liveIdentityIDs.any(wifiAwareIdentityIDs::contains) + val isDirect = liveIdentityIDs.any(directPeerIdentityIDs::contains) || + conversation.connectedPeerID?.let { peerID -> + runCatching { + viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true + }.getOrDefault(false) + } == true + val connectionDescription = meshConnectionDescription( + isWifiAware = isWifiAware, + isDirect = isDirect + ) + val messagePreview = when (conversation.latestMessageType) { + BitchatMessageType.Image -> stringResource(R.string.notification_sent_image) + BitchatMessageType.Audio -> stringResource(R.string.notification_sent_voice) + BitchatMessageType.File -> conversation.latestMessagePreview + .takeIf(String::isNotBlank) + ?.let { "๐Ÿ“Ž $it" } + ?: stringResource(R.string.notification_sent_file) + BitchatMessageType.Message -> conversation.latestMessagePreview.ifBlank { "โ€ฆ" } + } + val presenceDescription = when { + conversation.isConnected -> connectionDescription + conversation.transport == DirectMessageTransport.NOSTR -> + stringResource(R.string.offline_reachable_via_nostr) + else -> stringResource(R.string.offline_not_in_mesh) + } + val peerIdentity = conversation.nostrPubkey + ?.let(viewModel::peerIdentityForNostrPubkey) + ?: viewModel.peerIdentityForMeshPeer(conversation.conversationID) + val assignedColor = colorForPeer(peerIdentity, palette) + val (baseNameRaw, suffix) = splitSuffix(conversation.displayName) + val relativeTime = remember(conversation.latestMessageAt) { + DateUtils.getRelativeTimeSpanString( + conversation.latestMessageAt, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(colorScheme.surface) + .semantics { + stateDescription = presenceDescription + customActions = listOf( + CustomAccessibilityAction(deleteDescription) { + onDeleteRequested() + true + } + ) + } + .clickable(onClick = onClick) + .padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center + ) { + when { + conversation.isConnected -> Icon( + painter = painterResource( + conversationTransportIcon( + isReachedOverInternet = false, + isWifiAware = isWifiAware, + isDirect = isDirect + ) + ), + contentDescription = connectionDescription, + modifier = Modifier.size(PeerRowIconSize), + tint = colorScheme.onSurfaceVariant + ) + conversation.transport == DirectMessageTransport.NOSTR -> Icon( + painter = painterResource(R.drawable.ic_spec_globe), + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentPurple + ) + else -> Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.offline_not_in_mesh), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textTertiary + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = truncateNickname(baseNameRaw), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (conversation.unreadCount > 0) { + FontWeight.Bold + } else { + FontWeight.Medium + }, + color = assignedColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (suffix.isNotEmpty()) { + Text( + text = suffix, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = assignedColor.copy(alpha = SUFFIX_ALPHA) + ) + } + } + Text( + text = messagePreview, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = palette.textTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = relativeTime, + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + color = palette.textTertiary, + maxLines = 1 + ) + UnreadBadge( + count = conversation.unreadCount, + colorScheme = colorScheme + ) + } + } +} + @Composable private fun PeerItem( peerID: String, @@ -755,6 +979,10 @@ private fun PeerItem( showHashSuffix: Boolean = true ) { val currentNickname by viewModel.nickname.collectAsStateWithLifecycle() + val connectionDescription = meshConnectionDescription( + isWifiAware = isWifiAware, + isDirect = isDirect + ) // Split display name for hashtag suffix support (iOS-compatible) val (baseNameRaw, suffixRaw) = splitSuffix(displayName) val baseName = truncateNickname(baseNameRaw) @@ -816,11 +1044,7 @@ private fun PeerItem( isDirect = isDirect ) ), - contentDescription = when { - isWifiAware -> "Direct Wi-Fi Aware" - isDirect -> "Direct Bluetooth" - else -> "Routed" - }, + contentDescription = connectionDescription, modifier = Modifier.size(PeerRowIconSize), tint = colorScheme.onSurfaceVariant ) @@ -884,6 +1108,18 @@ private fun PeerItem( } } +@Composable +private fun meshConnectionDescription( + isWifiAware: Boolean, + isDirect: Boolean +): String = stringResource( + when { + isWifiAware -> R.string.cd_direct_wifi_aware + isDirect -> R.string.cd_direct_bluetooth + else -> R.string.cd_routed_mesh + } +) + /** * Reusable unread badge component for both channels and private messages */ diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 41a610f4..e3be65fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -3,7 +3,6 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.services.ContactDirectory -import com.bitchat.android.services.PrivateMessageArrivalOrder import java.util.* import java.util.Collections @@ -103,6 +102,15 @@ class MessageManager(private val state: ChatState) { fun addPrivateMessage(peerID: String, message: BitchatMessage) { val conversationID = ContactDirectory.canonicalConversationId(peerID) + val accepted = try { + com.bitchat.android.services.AppStateStore.addPrivateMessage( + conversationID, + message + ) + } catch (_: Exception) { + false + } + if (!accepted) return val currentPrivateChats = state.getPrivateChatsValue().toMutableMap() if (!currentPrivateChats.containsKey(conversationID)) { currentPrivateChats[conversationID] = mutableListOf() @@ -111,14 +119,15 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages - // Record the local arrival sequence before canonicalizing UI aliases. - PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) - // Reflect into process-wide store - try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } // Mark as unread if not currently viewing this chat - if (state.getSelectedPrivateChatPeerValue() != conversationID && message.sender != state.getNicknameValue()) { + val selectedConversationID = state.getSelectedPrivateChatPeerValue() + ?.let(ContactDirectory::canonicalConversationId) + if ( + selectedConversationID != conversationID && + message.sender != state.getNicknameValue() + ) { val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() currentUnread.add(conversationID) state.setUnreadPrivateMessages(currentUnread) @@ -128,6 +137,16 @@ class MessageManager(private val state: ChatState) { // Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store) fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) { val conversationID = ContactDirectory.canonicalConversationId(peerID) + val accepted = try { + com.bitchat.android.services.AppStateStore.addPrivateMessage( + peerID = conversationID, + msg = message, + forceRead = true + ) + } catch (_: Exception) { + false + } + if (!accepted) return val currentPrivateChats = state.getPrivateChatsValue().toMutableMap() if (!currentPrivateChats.containsKey(conversationID)) { currentPrivateChats[conversationID] = mutableListOf() @@ -135,18 +154,19 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages - // Record the local arrival sequence before canonicalizing UI aliases. - PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) - // Reflect into process-wide store - try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } } fun clearPrivateMessages(peerID: String) { val conversationID = ContactDirectory.canonicalConversationId(peerID) + com.bitchat.android.services.AppStateStore.deletePrivateConversation(conversationID) val updatedChats = state.getPrivateChatsValue().toMutableMap() - updatedChats[conversationID] = emptyList() + updatedChats.keys.removeAll { key -> + ContactDirectory.canonicalConversationId(key) + .equals(conversationID, ignoreCase = true) + } state.setPrivateChats(updatedChats) + clearPrivateUnreadMessages(conversationID) } fun initializePrivateChat(peerID: String) { @@ -325,7 +345,10 @@ class MessageManager(private val state: ChatState) { changed = true } } - if (changed) state.setPrivateChats(chats) + if (changed) { + state.setPrivateChats(chats.filterValues { it.isNotEmpty() }) + com.bitchat.android.services.AppStateStore.removePrivateMessage(messageID) + } } // Channels run { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c54a2273..883e14d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,13 @@ Nostr reachable Messages + Conversations + Offline ยท not in mesh + Offline from mesh ยท reachable via Nostr + Delete + Delete conversation + Delete conversation? + Delete your conversation with %1$s from this device? Location notes Teleported Tor status @@ -252,6 +259,9 @@ Checking Battery Optimization Battery Optimization Not Supported Bluetooth + Direct Wi-Fi Aware + Direct Bluetooth + Routed mesh Unread message Open map Remove bookmark diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 90ab0034..e095ea01 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -4,6 +4,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -189,4 +190,53 @@ class AppStateStoreTest { .deliveryStatus assertTrue(status is DeliveryStatus.Read) } + + @Test + fun `long lived process applies the same bounded private history policy`() { + AppStateStore.setNickname("me") + repeat(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION) { index -> + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "incoming-$index", + sender = "alice", + content = "message", + timestamp = Date(index.toLong()), + isPrivate = true + ) + ) + } + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "latest-outgoing", + sender = "me", + content = "latest", + timestamp = Date(Long.MAX_VALUE), + isPrivate = true + ) + ) + + val retained = AppStateStore.privateMessages.value.getValue("peer-a") + assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, retained.size) + assertEquals("incoming-1", retained.first().id) + assertEquals("latest-outgoing", retained.last().id) + } + + @Test + fun `private message admission is atomic and can durably classify known read messages`() { + val message = BitchatMessage( + id = "same-transport-message", + sender = "alice", + content = "hello", + timestamp = Date(1L), + isPrivate = true + ) + + assertTrue(AppStateStore.addPrivateMessage("peer-a", message, forceRead = true)) + assertFalse(AppStateStore.addPrivateMessage("peer-a", message)) + + assertEquals(1, AppStateStore.privateMessages.value.getValue("peer-a").size) + assertTrue(AppStateStore.isPrivateMessageRead(message.id)) + } } From d3f01f27c6b4b051134af4be159a5aab6991e11e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:41:24 +0200 Subject: [PATCH 2/5] persistent --- .../services/ConversationRepository.kt | 1037 +++++++++++++++++ .../bitchat/android/ui/ConversationSummary.kt | 135 +++ .../file/FileUtilsConversationCleanupTest.kt | 67 ++ .../services/ConversationDatabaseTest.kt | 219 ++++ .../services/ConversationRepositoryTest.kt | 77 ++ .../android/ui/ConversationSummaryTest.kt | 177 +++ 6 files changed, 1712 insertions(+) create mode 100644 app/src/main/java/com/bitchat/android/services/ConversationRepository.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsConversationCleanupTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt new file mode 100644 index 00000000..21ed56a4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -0,0 +1,1037 @@ +package com.bitchat.android.services + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import java.util.Date +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Process-wide, serialized persistence for private conversations. + * + * Android's SQLite database is deliberately kept behind this small repository instead of leaking + * cursors or database threading into the mesh and UI layers. Every mutation is queued on one + * writer so a delete/merge followed by a newly arriving message is applied in the same order that + * [AppStateStore] publishes it. + */ +class ConversationRepository internal constructor( + context: Context, + private val dispatcher: CoroutineDispatcher = Executors + .newSingleThreadExecutor { runnable -> + Thread(runnable, "conversation-store").apply { isDaemon = true } + } + .asCoroutineDispatcher(), + databaseName: String = ConversationDatabase.DEFAULT_DATABASE_NAME +) { + companion object { + private const val TAG = "ConversationRepository" + + @Volatile + private var instance: ConversationRepository? = null + + fun getInstance(context: Context): ConversationRepository = + instance ?: synchronized(this) { + instance ?: ConversationRepository(context.applicationContext).also { + instance = it + } + } + + fun tryGetInstance(): ConversationRepository? = instance + } + + private val database = ConversationDatabase( + context = context.applicationContext, + databaseName = databaseName + ) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val initialized = AtomicBoolean(false) + + internal fun initialize(onLoaded: (PersistedConversationSnapshot) -> Unit) { + if (!initialized.compareAndSet(false, true)) return + enqueueSnapshotLoad(pruneFirst = true, onLoaded = onLoaded) + } + + /** + * Re-reads persisted history even when this process already initialized the repository. + * + * This is required when the app's controlled shutdown cleared [AppStateStore], but Android + * reused the still-running process when the user immediately reopened the UI. + */ + internal fun reload(onLoaded: (PersistedConversationSnapshot) -> Unit) { + enqueueSnapshotLoad(pruneFirst = false, onLoaded = onLoaded) + } + + private fun enqueueSnapshotLoad( + pruneFirst: Boolean, + onLoaded: (PersistedConversationSnapshot) -> Unit + ) { + scope.launch { + try { + if (pruneFirst) database.pruneToRetentionLimits() + onLoaded(database.loadSnapshot()) + } catch (error: Exception) { + Log.e(TAG, "Unable to restore private conversations", error) + } + } + } + + /** + * Suspends until all persistence work queued before this call has finished. + */ + suspend fun awaitPendingWrites() { + withContext(dispatcher) { Unit } + } + + fun upsertMessage( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ) { + scope.launch { + try { + database.upsertMessage( + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + message = message, + isRead = isRead + ) + } catch (error: Exception) { + Log.e(TAG, "Unable to persist private message: ${error.message}") + } + } + } + + fun updateDeliveryStatus(messageID: String, status: DeliveryStatus) { + scope.launch { + try { + database.updateDeliveryStatus(messageID, status) + } catch (error: Exception) { + Log.e(TAG, "Unable to persist delivery status: ${error.message}") + } + } + } + + fun markRead(messageID: String) { + scope.launch { + try { + database.markRead(messageID) + } catch (error: Exception) { + Log.e(TAG, "Unable to persist local read state: ${error.message}") + } + } + } + + fun mergeAliases(targetConversationID: String, aliases: Set) { + scope.launch { + try { + database.mergeAliases(targetConversationID, aliases) + } catch (error: Exception) { + Log.e(TAG, "Unable to merge conversation aliases: ${error.message}") + } + } + } + + fun deleteConversation(conversationID: String, aliases: Set) { + scope.launch { + try { + database.deleteConversation(conversationID, aliases) + } catch (error: Exception) { + Log.e(TAG, "Unable to delete private conversation: ${error.message}") + } + } + } + + fun deleteMessage(messageID: String) { + scope.launch { + try { + database.deleteMessage(messageID) + } catch (error: Exception) { + Log.e(TAG, "Unable to delete private message: ${error.message}") + } + } + } + + fun clearAll() { + scope.launch { + try { + database.clearAll() + } catch (error: Exception) { + Log.e(TAG, "Unable to clear private conversations: ${error.message}") + } + } + } +} + +internal data class PersistedConversationSnapshot( + val chats: Map>, + val readMessageIDs: Set, + val arrivalOrder: List, + val deletedMessageIDs: Set +) + +/** + * Versioned SQLite schema for bounded private-message history. + * + * Conversation rows are intentionally tiny and remain until explicit deletion. Message rows are + * bounded by per-conversation, global-row, and payload-byte limits. Media bytes stay in the app's + * existing media storage; SQLite only stores the message metadata/path already present in content. + */ +internal class ConversationDatabase( + context: Context, + databaseName: String = DEFAULT_DATABASE_NAME, + private val maxMessagesPerConversation: Int = MAX_MESSAGES_PER_CONVERSATION, + private val maxMessagesTotal: Int = MAX_MESSAGES_TOTAL, + private val maxPayloadBytes: Long = MAX_PAYLOAD_BYTES +) : SQLiteOpenHelper(context, databaseName, null, DATABASE_VERSION) { + + companion object { + const val MAX_MESSAGES_PER_CONVERSATION = 1_000 + const val MAX_MESSAGES_TOTAL = 20_000 + const val MAX_PAYLOAD_BYTES = 32L * 1024L * 1024L + + internal const val DEFAULT_DATABASE_NAME = "private_conversations.db" + private const val DATABASE_VERSION = 1 + private const val PRUNE_INTERVAL = 64 + private const val PRUNE_BATCH_SIZE = 256 + } + + private var writesSinceGlobalPrune = 0 + + init { + setWriteAheadLoggingEnabled(true) + } + + override fun onConfigure(db: SQLiteDatabase) { + super.onConfigure(db) + db.setForeignKeyConstraintsEnabled(true) + db.execSQL("PRAGMA auto_vacuum = INCREMENTAL") + } + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE conversations ( + conversation_id TEXT COLLATE NOCASE PRIMARY KEY NOT NULL, + display_name TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE conversation_aliases ( + alias TEXT COLLATE NOCASE PRIMARY KEY NOT NULL, + conversation_id TEXT COLLATE NOCASE NOT NULL, + FOREIGN KEY(conversation_id) REFERENCES conversations(conversation_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE private_messages ( + arrival_sequence INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE NOT NULL, + conversation_id TEXT COLLATE NOCASE NOT NULL, + sender TEXT NOT NULL, + content TEXT NOT NULL, + message_type INTEGER NOT NULL, + sent_at INTEGER NOT NULL, + is_relay INTEGER NOT NULL, + original_sender TEXT, + is_private INTEGER NOT NULL, + recipient_nickname TEXT, + sender_peer_id TEXT, + mentions_json TEXT, + channel_name TEXT, + encrypted_content BLOB, + is_encrypted INTEGER NOT NULL, + delivery_type INTEGER NOT NULL, + delivery_text TEXT, + delivery_at INTEGER, + delivery_reached INTEGER, + delivery_total INTEGER, + sender_nostr_pubkey TEXT, + is_read INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(conversation_id) REFERENCES conversations(conversation_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX idx_private_messages_conversation_arrival " + + "ON private_messages(conversation_id, arrival_sequence)" + ) + db.execSQL( + "CREATE INDEX idx_private_messages_read_arrival " + + "ON private_messages(is_read, arrival_sequence)" + ) + db.execSQL( + "CREATE INDEX idx_conversation_aliases_conversation " + + "ON conversation_aliases(conversation_id)" + ) + db.execSQL( + """ + CREATE TABLE deleted_private_messages ( + message_id TEXT PRIMARY KEY NOT NULL, + deleted_at INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX idx_deleted_private_messages_time " + + "ON deleted_private_messages(deleted_at)" + ) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // Version 1 is the first persisted-conversation schema. Future versions must migrate + // without dropping private history. + check(oldVersion == newVersion) { + "Missing conversation database migration from $oldVersion to $newVersion" + } + } + + fun loadSnapshot(): PersistedConversationSnapshot { + val chats = linkedMapOf>() + val readIDs = linkedSetOf() + val arrivalOrder = mutableListOf() + val deletedMessageIDs = linkedSetOf() + readableDatabase.query( + "private_messages", + MESSAGE_COLUMNS, + null, + null, + null, + null, + "arrival_sequence ASC" + ).use { cursor -> + while (cursor.moveToNext()) { + val conversationID = cursor.string("conversation_id") + val message = cursor.toMessage() + chats.getOrPut(conversationID) { mutableListOf() }.add(message) + arrivalOrder.add(message.id) + if (cursor.boolean("is_read")) readIDs.add(message.id) + } + } + readableDatabase.query( + "deleted_private_messages", + arrayOf("message_id"), + null, + null, + null, + null, + "deleted_at ASC" + ).use { cursor -> + while (cursor.moveToNext()) deletedMessageIDs.add(cursor.getString(0)) + } + return PersistedConversationSnapshot( + chats = chats.mapValues { it.value.toList() }, + readMessageIDs = readIDs, + arrivalOrder = arrivalOrder, + deletedMessageIDs = deletedMessageIDs + ) + } + + fun upsertMessage( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ) { + val normalizedID = conversationID.trim() + if (normalizedID.isBlank()) return + val now = System.currentTimeMillis() + writableDatabase.inTransaction { + if (isDeletedMessageLocked(this, message.id)) return@inTransaction + mergeAliasesLocked( + db = this, + targetConversationID = normalizedID, + aliases = aliases + normalizedID, + displayName = displayName, + now = now + ) + val values = message.toContentValues(normalizedID, isRead) + val inserted = insertWithOnConflict( + "private_messages", + null, + values, + SQLiteDatabase.CONFLICT_IGNORE + ) + if (inserted == -1L) { + val existingConversation = rawQuery( + "SELECT conversation_id, is_read FROM private_messages WHERE message_id = ?", + arrayOf(message.id) + ).use { cursor -> + if (cursor.moveToFirst()) { + cursor.string("conversation_id") to cursor.boolean("is_read") + } else { + null + } + } + if (existingConversation != null) { + val canonicalExisting = resolveStoredConversationLocked( + this, + existingConversation.first + ) + if (!canonicalExisting.equals(normalizedID, ignoreCase = true)) { + mergeAliasesLocked( + db = this, + targetConversationID = normalizedID, + aliases = aliases + canonicalExisting, + displayName = displayName, + now = now + ) + } + if (isRead && !existingConversation.second) { + update( + "private_messages", + ContentValues().apply { put("is_read", 1) }, + "message_id = ?", + arrayOf(message.id) + ) + } + } + } + updateConversationMetadataLocked(this, normalizedID, displayName, now) + pruneConversationLocked(this, normalizedID) + } + + writesSinceGlobalPrune += 1 + if (writesSinceGlobalPrune >= PRUNE_INTERVAL) { + writesSinceGlobalPrune = 0 + pruneToRetentionLimits() + } + } + + fun updateDeliveryStatus(messageID: String, status: DeliveryStatus) { + val db = writableDatabase + var found = false + val existing = db.rawQuery( + """ + SELECT delivery_type, delivery_text, delivery_at, delivery_reached, delivery_total + FROM private_messages WHERE message_id = ? + """.trimIndent(), + arrayOf(messageID) + ).use { cursor -> + if (cursor.moveToFirst()) { + found = true + cursor.toDeliveryStatus() + } else { + null + } + } + + if (!found) return + if (statusPriority(status) < statusPriority(existing)) return + db.update( + "private_messages", + deliveryValues(status), + "message_id = ?", + arrayOf(messageID) + ) + } + + fun markRead(messageID: String) { + writableDatabase.update( + "private_messages", + ContentValues().apply { put("is_read", 1) }, + "message_id = ?", + arrayOf(messageID) + ) + } + + fun mergeAliases(targetConversationID: String, aliases: Set) { + if (targetConversationID.isBlank()) return + writableDatabase.inTransaction { + mergeAliasesLocked( + db = this, + targetConversationID = targetConversationID, + aliases = aliases + targetConversationID, + displayName = null, + now = System.currentTimeMillis() + ) + } + } + + fun deleteConversation(conversationID: String, aliases: Set) { + writableDatabase.inTransaction { + val ids = linkedSetOf() + (aliases + conversationID).forEach { value -> + ids.add(resolveStoredConversationLocked(this, value)) + ids.add(value) + } + ids.filter { it.isNotBlank() }.forEach { id -> + execSQL( + """ + INSERT OR REPLACE INTO deleted_private_messages(message_id, deleted_at) + SELECT message_id, ? FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + """.trimIndent(), + arrayOf(System.currentTimeMillis(), id) + ) + } + ids.filter { it.isNotBlank() }.forEach { id -> + delete( + "conversations", + "conversation_id = ? COLLATE NOCASE", + arrayOf(id) + ) + } + pruneDeletedMessageIDsLocked(this) + } + } + + fun deleteMessage(messageID: String) { + writableDatabase.inTransaction { + insertWithOnConflict( + "deleted_private_messages", + null, + ContentValues().apply { + put("message_id", messageID) + put("deleted_at", System.currentTimeMillis()) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + delete("private_messages", "message_id = ?", arrayOf(messageID)) + delete( + "conversations", + """ + NOT EXISTS ( + SELECT 1 FROM private_messages + WHERE private_messages.conversation_id = conversations.conversation_id + ) + """.trimIndent(), + null + ) + pruneDeletedMessageIDsLocked(this) + } + } + + fun clearAll() { + writableDatabase.inTransaction { + delete("conversation_aliases", null, null) + delete("private_messages", null, null) + delete("conversations", null, null) + delete("deleted_private_messages", null, null) + execSQL("DELETE FROM sqlite_sequence WHERE name = 'private_messages'") + } + writableDatabase.rawQuery("PRAGMA incremental_vacuum", null).use { } + } + + fun pruneToRetentionLimits() { + val db = writableDatabase + db.inTransaction { + rawQuery( + "SELECT conversation_id FROM conversations", + null + ).use { cursor -> + while (cursor.moveToNext()) { + pruneConversationLocked(this, cursor.getString(0)) + } + } + + var stats = storageStatsLocked(this) + while ( + stats.first > maxMessagesTotal || + stats.second > maxPayloadBytes + ) { + val candidateLimit = if (stats.first > maxMessagesTotal) { + (stats.first - maxMessagesTotal) + .coerceAtMost(PRUNE_BATCH_SIZE.toLong()) + .toInt() + } else { + // Payload sizes vary, so recalculate after each removal instead of + // discarding an entire batch unnecessarily. + 1 + } + val candidates = pruneCandidatesLocked( + db = this, + readOnly = true, + preserveLatest = true, + limit = candidateLimit + ).ifEmpty { + pruneCandidatesLocked( + db = this, + readOnly = false, + preserveLatest = true, + limit = candidateLimit + ) + }.ifEmpty { + // A store with one message in every conversation still needs a hard bound. + // Prefer retiring read conversations before unread ones. + pruneCandidatesLocked( + db = this, + readOnly = true, + preserveLatest = false, + limit = candidateLimit + ) + }.ifEmpty { + pruneCandidatesLocked( + db = this, + readOnly = false, + preserveLatest = false, + limit = candidateLimit + ) + } + if (candidates.isEmpty()) break + tombstoneMessagesLocked(this, candidates) + candidates.forEach { messageID -> + delete("private_messages", "message_id = ?", arrayOf(messageID)) + } + deleteEmptyConversationsLocked(this) + pruneDeletedMessageIDsLocked(this) + stats = storageStatsLocked(this) + } + } + db.rawQuery("PRAGMA incremental_vacuum(128)", null).use { } + } + + private fun mergeAliasesLocked( + db: SQLiteDatabase, + targetConversationID: String, + aliases: Set, + displayName: String?, + now: Long + ) { + ensureConversationLocked(db, targetConversationID, displayName, now) + val normalizedAliases = aliases + .map(String::trim) + .filter(String::isNotBlank) + .toSet() + val sourceIDs = normalizedAliases + .mapTo(linkedSetOf()) { resolveStoredConversationLocked(db, it) } + .plus( + db.queryValues( + table = "conversation_aliases", + resultColumn = "conversation_id", + selectionValues = normalizedAliases + ) + ) + + sourceIDs + .filterNot { it.equals(targetConversationID, ignoreCase = true) } + .forEach { sourceID -> + db.update( + "private_messages", + ContentValues().apply { put("conversation_id", targetConversationID) }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(sourceID) + ) + db.update( + "conversation_aliases", + ContentValues().apply { put("conversation_id", targetConversationID) }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(sourceID) + ) + db.delete( + "conversations", + "conversation_id = ? COLLATE NOCASE", + arrayOf(sourceID) + ) + } + + (normalizedAliases + sourceIDs + targetConversationID).forEach { alias -> + db.insertWithOnConflict( + "conversation_aliases", + null, + ContentValues().apply { + put("alias", alias) + put("conversation_id", targetConversationID) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + updateConversationMetadataLocked(db, targetConversationID, displayName, now) + } + + private fun ensureConversationLocked( + db: SQLiteDatabase, + conversationID: String, + displayName: String?, + now: Long + ) { + db.insertWithOnConflict( + "conversations", + null, + ContentValues().apply { + put("conversation_id", conversationID) + put("display_name", displayName) + put("created_at", now) + put("updated_at", now) + }, + SQLiteDatabase.CONFLICT_IGNORE + ) + } + + private fun updateConversationMetadataLocked( + db: SQLiteDatabase, + conversationID: String, + displayName: String?, + now: Long + ) { + db.update( + "conversations", + ContentValues().apply { + if (!displayName.isNullOrBlank()) put("display_name", displayName) + put("updated_at", now) + }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID) + ) + } + + private fun resolveStoredConversationLocked(db: SQLiteDatabase, value: String): String { + if (value.isBlank()) return value + return db.rawQuery( + "SELECT conversation_id FROM conversation_aliases WHERE alias = ? COLLATE NOCASE", + arrayOf(value) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else value + } + } + + private fun isDeletedMessageLocked(db: SQLiteDatabase, messageID: String): Boolean = + db.longForQuery( + "SELECT COUNT(*) FROM deleted_private_messages WHERE message_id = ?", + arrayOf(messageID) + ) > 0L + + private fun pruneDeletedMessageIDsLocked(db: SQLiteDatabase) { + val excess = ( + db.longForQuery( + "SELECT COUNT(*) FROM deleted_private_messages", + emptyArray() + ) - maxMessagesTotal + ).coerceAtLeast(0L) + if (excess == 0L) return + db.execSQL( + """ + DELETE FROM deleted_private_messages + WHERE message_id IN ( + SELECT message_id FROM deleted_private_messages + ORDER BY deleted_at ASC + LIMIT ? + ) + """.trimIndent(), + arrayOf(excess) + ) + } + + private fun pruneConversationLocked(db: SQLiteDatabase, conversationID: String) { + val count = db.longForQuery( + "SELECT COUNT(*) FROM private_messages WHERE conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID) + ) + val excess = (count - maxMessagesPerConversation).coerceAtLeast(0L) + if (excess == 0L) return + db.rawQuery( + """ + SELECT message_id + FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + AND arrival_sequence < ( + SELECT MAX(arrival_sequence) + FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + ) + ORDER BY is_read DESC, arrival_sequence ASC + LIMIT ? + """.trimIndent(), + arrayOf(conversationID, conversationID, excess.toString()) + ).use { cursor -> + val ids = mutableListOf() + while (cursor.moveToNext()) ids.add(cursor.getString(0)) + tombstoneMessagesLocked(db, ids) + ids.forEach { db.delete("private_messages", "message_id = ?", arrayOf(it)) } + pruneDeletedMessageIDsLocked(db) + } + } + + private fun storageStatsLocked(db: SQLiteDatabase): Pair = + db.rawQuery( + """ + SELECT COUNT(*), + COALESCE(SUM( + length(content) + + COALESCE(length(encrypted_content), 0) + + COALESCE(length(mentions_json), 0) + ), 0) + FROM private_messages + """.trimIndent(), + null + ).use { cursor -> + cursor.moveToFirst() + cursor.getLong(0) to cursor.getLong(1) + } + + private fun pruneCandidatesLocked( + db: SQLiteDatabase, + readOnly: Boolean, + preserveLatest: Boolean, + limit: Int + ): List { + val readClause = if (readOnly) "AND candidate.is_read = 1" else "" + val newerMessageClause = if (preserveLatest) { + """ + AND EXISTS ( + SELECT 1 + FROM private_messages AS newer + WHERE newer.conversation_id = candidate.conversation_id + AND newer.arrival_sequence > candidate.arrival_sequence + ) + """.trimIndent() + } else { + "" + } + return db.rawQuery( + """ + SELECT candidate.message_id + FROM private_messages AS candidate + WHERE 1 = 1 + $newerMessageClause + $readClause + ORDER BY candidate.arrival_sequence ASC + LIMIT $limit + """.trimIndent(), + null + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + } + + private fun deleteEmptyConversationsLocked(db: SQLiteDatabase) { + db.delete( + "conversations", + """ + NOT EXISTS ( + SELECT 1 FROM private_messages + WHERE private_messages.conversation_id = conversations.conversation_id + ) + """.trimIndent(), + null + ) + } + + private fun tombstoneMessagesLocked( + db: SQLiteDatabase, + messageIDs: Collection + ) { + val deletedAt = System.currentTimeMillis() + messageIDs.forEach { messageID -> + db.insertWithOnConflict( + "deleted_private_messages", + null, + ContentValues().apply { + put("message_id", messageID) + put("deleted_at", deletedAt) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + } + + private fun SQLiteDatabase.queryValues( + table: String, + resultColumn: String, + selectionValues: Set + ): Set { + if (selectionValues.isEmpty()) return emptySet() + val placeholders = selectionValues.joinToString(",") { "?" } + return rawQuery( + "SELECT $resultColumn FROM $table WHERE alias IN ($placeholders)", + selectionValues.toTypedArray() + ).use { cursor -> + buildSet { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + } + + private fun SQLiteDatabase.longForQuery(sql: String, args: Array): Long = + rawQuery(sql, args).use { cursor -> + cursor.moveToFirst() + cursor.getLong(0) + } + + private inline fun SQLiteDatabase.inTransaction(block: SQLiteDatabase.() -> T): T { + beginTransaction() + return try { + val result = block() + setTransactionSuccessful() + result + } finally { + endTransaction() + } + } + + private fun BitchatMessage.toContentValues( + conversationID: String, + isRead: Boolean + ): ContentValues = ContentValues().apply { + put("message_id", id) + put("conversation_id", conversationID) + put("sender", sender) + put("content", content) + put("message_type", type.ordinal) + put("sent_at", timestamp.time) + put("is_relay", isRelay.asInt()) + put("original_sender", originalSender) + put("is_private", isPrivate.asInt()) + put("recipient_nickname", recipientNickname) + put("sender_peer_id", senderPeerID) + put("mentions_json", mentions?.let { JSONArray(it).toString() }) + put("channel_name", channel) + put("encrypted_content", encryptedContent) + put("is_encrypted", isEncrypted.asInt()) + putAll(deliveryValues(deliveryStatus)) + put("sender_nostr_pubkey", senderNostrPubkey) + put("is_read", isRead.asInt()) + } + + private fun deliveryValues(status: DeliveryStatus?): ContentValues = ContentValues().apply { + when (status) { + null -> put("delivery_type", 0) + DeliveryStatus.Sending -> put("delivery_type", 1) + DeliveryStatus.Sent -> put("delivery_type", 2) + is DeliveryStatus.Delivered -> { + put("delivery_type", 3) + put("delivery_text", status.to) + put("delivery_at", status.at.time) + } + is DeliveryStatus.Read -> { + put("delivery_type", 4) + put("delivery_text", status.by) + put("delivery_at", status.at.time) + } + is DeliveryStatus.Failed -> { + put("delivery_type", 5) + put("delivery_text", status.reason) + } + is DeliveryStatus.PartiallyDelivered -> { + put("delivery_type", 6) + put("delivery_reached", status.reached) + put("delivery_total", status.total) + } + } + } + + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Failed -> 0 + DeliveryStatus.Sending -> 1 + DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + } + + private fun Cursor.toMessage(): BitchatMessage = BitchatMessage( + id = string("message_id"), + sender = string("sender"), + content = string("content"), + type = BitchatMessageType.entries.getOrElse(int("message_type")) { + BitchatMessageType.Message + }, + timestamp = Date(long("sent_at")), + isRelay = boolean("is_relay"), + originalSender = nullableString("original_sender"), + isPrivate = boolean("is_private"), + recipientNickname = nullableString("recipient_nickname"), + senderPeerID = nullableString("sender_peer_id"), + mentions = nullableString("mentions_json")?.let(::jsonStringList), + channel = nullableString("channel_name"), + encryptedContent = blobOrNull("encrypted_content"), + isEncrypted = boolean("is_encrypted"), + deliveryStatus = toDeliveryStatus(), + senderNostrPubkey = nullableString("sender_nostr_pubkey") + ) + + private fun Cursor.toDeliveryStatus(): DeliveryStatus? = when (int("delivery_type")) { + 1 -> DeliveryStatus.Sending + 2 -> DeliveryStatus.Sent + 3 -> DeliveryStatus.Delivered( + to = nullableString("delivery_text").orEmpty(), + at = Date(nullableLong("delivery_at") ?: 0L) + ) + 4 -> DeliveryStatus.Read( + by = nullableString("delivery_text").orEmpty(), + at = Date(nullableLong("delivery_at") ?: 0L) + ) + 5 -> DeliveryStatus.Failed(nullableString("delivery_text").orEmpty()) + 6 -> DeliveryStatus.PartiallyDelivered( + reached = nullableInt("delivery_reached") ?: 0, + total = nullableInt("delivery_total") ?: 0 + ) + else -> null + } + + private fun jsonStringList(json: String): List { + val array = JSONArray(json) + return buildList(array.length()) { + for (index in 0 until array.length()) add(array.getString(index)) + } + } + + private fun Cursor.index(column: String): Int = getColumnIndexOrThrow(column) + private fun Cursor.string(column: String): String = getString(index(column)) + private fun Cursor.nullableString(column: String): String? = + index(column).let { if (isNull(it)) null else getString(it) } + private fun Cursor.int(column: String): Int = getInt(index(column)) + private fun Cursor.nullableInt(column: String): Int? = + index(column).let { if (isNull(it)) null else getInt(it) } + private fun Cursor.long(column: String): Long = getLong(index(column)) + private fun Cursor.nullableLong(column: String): Long? = + index(column).let { if (isNull(it)) null else getLong(it) } + private fun Cursor.boolean(column: String): Boolean = int(column) != 0 + private fun Cursor.blobOrNull(column: String): ByteArray? = + index(column).let { if (isNull(it)) null else getBlob(it) } + private fun Boolean.asInt(): Int = if (this) 1 else 0 + + private val MESSAGE_COLUMNS = arrayOf( + "arrival_sequence", + "message_id", + "conversation_id", + "sender", + "content", + "message_type", + "sent_at", + "is_relay", + "original_sender", + "is_private", + "recipient_nickname", + "sender_peer_id", + "mentions_json", + "channel_name", + "encrypted_content", + "is_encrypted", + "delivery_type", + "delivery_text", + "delivery_at", + "delivery_reached", + "delivery_total", + "sender_nostr_pubkey", + "is_read" + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt new file mode 100644 index 00000000..6d327413 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt @@ -0,0 +1,135 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.services.PrivateMessageArrivalOrder + +/** + * Presence-independent presentation state for every retained private conversation. + */ +internal data class ConversationSummary( + val conversationID: String, + val displayName: String, + val unreadCount: Int, + val latestMessageAt: Long, + val latestActivityOrder: Long, + val latestMessageType: BitchatMessageType, + val latestMessagePreview: String, + val transport: DirectMessageTransport, + val nostrPubkey: String?, + val identityAliases: Set, + val isConnected: Boolean = false, + val connectedPeerID: String? = null, + val sourceGeohash: String? = null +) + +internal fun buildConversationSummaries( + unreadConversationIDs: Set, + privateChats: Map>, + currentUserIdentifiers: Set, + canonicalize: (String) -> String, + isMessageRead: (BitchatMessage) -> Boolean +): List { + if (privateChats.isEmpty()) return emptyList() + + val currentUsers = currentUserIdentifiers.filterTo(mutableSetOf()) { it.isNotBlank() } + val unreadCanonicalIDs = unreadConversationIDs + .mapTo(mutableSetOf()) { canonicalize(it).lowercase() } + val aliasesByCanonicalID = linkedMapOf>() + val messagesByCanonicalID = linkedMapOf>() + + privateChats.forEach { (sourceID, messages) -> + val canonicalID = canonicalize(sourceID) + aliasesByCanonicalID + .getOrPut(canonicalID) { linkedSetOf() } + .add(sourceID) + messagesByCanonicalID + .getOrPut(canonicalID) { mutableListOf() } + .addAll(messages) + } + + return messagesByCanonicalID.mapNotNull { (conversationID, sourceMessages) -> + val messages = sourceMessages.distinctBy { it.id } + if (messages.isEmpty()) return@mapNotNull null + + fun activityOrder(message: BitchatMessage): Long = + PrivateMessageArrivalOrder.sequenceOf(message.id) ?: message.timestamp.time + + val latest = messages.maxWithOrNull( + compareBy(::activityOrder).thenBy { it.id } + ) ?: return@mapNotNull null + val incoming = messages.filterNot { + it.sender in currentUsers || it.sender == "system" + } + val latestIncoming = incoming.maxWithOrNull( + compareBy(::activityOrder).thenBy { it.id } + ) + val canonicalUnread = conversationID.lowercase() in unreadCanonicalIDs + val unreadCount = if (canonicalUnread) { + incoming.count { !isMessageRead(it) }.coerceAtLeast(1) + } else { + 0 + } + val aliases = aliasesByCanonicalID[conversationID].orEmpty() + val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey + val isNostrConversation = nostrPubkey != null || + aliases.any(::isNostrConversationKey) || + isNostrConversationKey(conversationID) + val displayName = latestIncoming + ?.sender + ?.takeIf { it.isNotBlank() } + ?: latest.recipientNickname + ?.takeIf { it.isNotBlank() } + ?: latest.sender.takeIf { + it.isNotBlank() && it !in currentUsers && it != "system" + } + ?: conversationID.take(12) + + ConversationSummary( + conversationID = conversationID, + displayName = displayName, + unreadCount = unreadCount, + latestMessageAt = latest.timestamp.time, + latestActivityOrder = activityOrder(latest), + latestMessageType = latest.type, + latestMessagePreview = latest.conversationPreview(), + transport = if (isNostrConversation) { + DirectMessageTransport.NOSTR + } else { + DirectMessageTransport.MESH + }, + nostrPubkey = nostrPubkey, + identityAliases = (aliases + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } + ) + } +} + +internal fun sortConversationSummaries( + conversations: List +): List = conversations.sortedWith( + compareByDescending { it.isConnected } + .thenByDescending { it.unreadCount > 0 } + .thenByDescending { it.latestActivityOrder } + .thenBy { it.displayName.lowercase() } + .thenBy { it.conversationID } +) + +private fun isNostrConversationKey(value: String): Boolean = + value.startsWith("nostr_") || value.startsWith("nostr:") + +private fun BitchatMessage.conversationPreview(): String { + val preview = when (type) { + BitchatMessageType.File -> content + .substringAfterLast('/') + .substringAfterLast('\\') + else -> content + } + return preview + .replace(CONVERSATION_PREVIEW_WHITESPACE, " ") + .trim() + .take(MAX_CONVERSATION_PREVIEW_LENGTH) +} + +private val CONVERSATION_PREVIEW_WHITESPACE = Regex("\\s+") +private const val MAX_CONVERSATION_PREVIEW_LENGTH = 240 diff --git a/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsConversationCleanupTest.kt b/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsConversationCleanupTest.kt new file mode 100644 index 00000000..3bed330b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsConversationCleanupTest.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.features.file + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.Date +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class FileUtilsConversationCleanupTest { + + @Test + fun `deleting conversation removes only unreferenced app-owned media`() { + val context = ApplicationProvider.getApplicationContext() + val testDirectory = File( + context.cacheDir, + "conversation-cleanup-${UUID.randomUUID()}" + ).apply { mkdirs() } + val deletedOnly = File(testDirectory, "deleted.jpg").apply { + writeBytes(byteArrayOf(1)) + } + val shared = File(testDirectory, "shared.jpg").apply { + writeBytes(byteArrayOf(2)) + } + val outsideAppStorage = File.createTempFile( + "conversation-cleanup-", + ".jpg" + ).apply { + writeBytes(byteArrayOf(3)) + } + + try { + FileUtils.deleteConversationMedia( + context = context, + deletedMessages = listOf( + mediaMessage("deleted", deletedOnly), + mediaMessage("shared-deleted", shared), + mediaMessage("outside", outsideAppStorage) + ), + retainedMessages = listOf(mediaMessage("shared-retained", shared)) + ) + + assertFalse(deletedOnly.exists()) + assertTrue(shared.exists()) + assertTrue(outsideAppStorage.exists()) + } finally { + testDirectory.deleteRecursively() + outsideAppStorage.delete() + } + } + + private fun mediaMessage(id: String, file: File) = BitchatMessage( + id = id, + sender = "alice", + content = file.absolutePath, + type = BitchatMessageType.Image, + timestamp = Date(1L), + isPrivate = true + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt new file mode 100644 index 00000000..4f768956 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt @@ -0,0 +1,219 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class ConversationDatabaseTest { + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var database: ConversationDatabase + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "conversation-test-${UUID.randomUUID()}.db" + database = ConversationDatabase(context, databaseName) + } + + @After + fun tearDown() { + database.close() + context.deleteDatabase(databaseName) + } + + @Test + fun `message fields read state and delivery status survive reopen`() { + val message = BitchatMessage( + id = "round-trip", + sender = "alice", + content = "photo path", + type = BitchatMessageType.Image, + timestamp = Date(123_456L), + isRelay = true, + originalSender = "alice-original", + isPrivate = true, + recipientNickname = "me", + senderPeerID = "0123456789abcdef", + mentions = listOf("me", "bob"), + channel = "private", + encryptedContent = byteArrayOf(1, 2, 3), + isEncrypted = true, + deliveryStatus = DeliveryStatus.Delivered("me", Date(123_999L)), + senderNostrPubkey = "a".repeat(64) + ) + + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice", "0123456789abcdef"), + displayName = "alice", + message = message, + isRead = false + ) + database.markRead(message.id) + database.updateDeliveryStatus( + message.id, + DeliveryStatus.Read("me", Date(124_000L)) + ) + database.close() + + database = ConversationDatabase(context, databaseName) + val restored = database.loadSnapshot() + val restoredMessage = restored.chats.getValue("contact_alice").single() + + assertEquals(message.id, restoredMessage.id) + assertEquals(message.sender, restoredMessage.sender) + assertEquals(message.content, restoredMessage.content) + assertEquals(message.type, restoredMessage.type) + assertEquals(message.timestamp, restoredMessage.timestamp) + assertEquals(message.mentions, restoredMessage.mentions) + assertEquals(message.senderNostrPubkey, restoredMessage.senderNostrPubkey) + assertArrayEquals(message.encryptedContent, restoredMessage.encryptedContent) + assertEquals( + DeliveryStatus.Read("me", Date(124_000L)), + restoredMessage.deliveryStatus + ) + assertEquals(setOf(message.id), restored.readMessageIDs) + assertEquals(listOf(message.id), restored.arrivalOrder) + } + + @Test + fun `aliases merge into one conversation and duplicate transport delivery stays unique`() { + val first = message("first", "alice", 100L) + val second = message("second", "alice", 200L) + + database.upsertMessage("mesh-alias", setOf("mesh-alias"), "alice", first, false) + database.upsertMessage("nostr_alias", setOf("nostr_alias"), "alice", second, false) + database.mergeAliases( + targetConversationID = "contact_alice", + aliases = setOf("mesh-alias", "nostr_alias", "contact_alice") + ) + database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "nostr_alias"), + "alice", + first, + false + ) + + val restored = database.loadSnapshot() + assertEquals(setOf("contact_alice"), restored.chats.keys) + assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id }) + } + + @Test + fun `conversation deletion cascades messages while a later message starts fresh`() { + database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "contact_alice"), + "alice", + message("old", "alice", 100L), + false + ) + + database.deleteConversation( + "contact_alice", + setOf("mesh-alias", "contact_alice") + ) + assertTrue(database.loadSnapshot().chats.isEmpty()) + + database.close() + database = ConversationDatabase(context, databaseName) + database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "contact_alice"), + "alice", + message("old", "alice", 100L), + false + ) + assertTrue(database.loadSnapshot().chats.isEmpty()) + + database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "contact_alice"), + "alice", + message("new", "alice", 200L), + false + ) + val restored = database.loadSnapshot() + assertEquals(listOf("new"), restored.chats.getValue("contact_alice").map { it.id }) + assertFalse(restored.readMessageIDs.contains("old")) + assertTrue(restored.deletedMessageIDs.contains("old")) + } + + @Test + fun `per conversation retention keeps the newest bounded history`() { + val total = ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION + 5 + repeat(total) { index -> + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice"), + displayName = "alice", + message = message("message-$index", "alice", index.toLong()), + isRead = true + ) + } + + val snapshot = database.loadSnapshot() + val messages = snapshot.chats.getValue("contact_alice") + assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, messages.size) + assertEquals("message-5", messages.first().id) + assertEquals("message-${total - 1}", messages.last().id) + assertEquals( + (0 until 5).mapTo(mutableSetOf()) { "message-$it" }, + snapshot.deletedMessageIDs + ) + } + + @Test + fun `global retention is hard bounded when every conversation has one message`() { + database.close() + context.deleteDatabase(databaseName) + database = ConversationDatabase( + context = context, + databaseName = databaseName, + maxMessagesPerConversation = 10, + maxMessagesTotal = 3, + maxPayloadBytes = Long.MAX_VALUE + ) + repeat(4) { index -> + database.upsertMessage( + conversationID = "peer-$index", + aliases = setOf("peer-$index"), + displayName = "peer $index", + message = message("single-$index", "peer $index", index.toLong()), + isRead = true + ) + } + + database.pruneToRetentionLimits() + + val snapshot = database.loadSnapshot() + assertEquals(3, snapshot.chats.values.sumOf { it.size }) + assertFalse(snapshot.chats.containsKey("peer-0")) + assertTrue(snapshot.deletedMessageIDs.contains("single-0")) + } + + private fun message(id: String, sender: String, timestamp: Long) = BitchatMessage( + id = id, + sender = sender, + content = "hello-$id", + timestamp = Date(timestamp), + isPrivate = true, + senderPeerID = "0123456789abcdef" + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt new file mode 100644 index 00000000..d0bba0db --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt @@ -0,0 +1,77 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +class ConversationRepositoryTest { + private lateinit var context: Context + private lateinit var databaseName: String + private val executor = Executors.newSingleThreadExecutor() + private val dispatcher = executor.asCoroutineDispatcher() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "conversation-repository-test-${UUID.randomUUID()}.db" + } + + @After + fun tearDown() { + dispatcher.close() + context.deleteDatabase(databaseName) + } + + @Test + fun `reload restores persisted history after initial process restore`() { + val repository = ConversationRepository( + context = context, + dispatcher = dispatcher, + databaseName = databaseName + ) + val message = BitchatMessage( + id = "persisted-message", + sender = "alice", + content = "survives restart", + timestamp = Date(100L), + isPrivate = true + ) + repository.upsertMessage( + conversationID = "peer-alice", + aliases = setOf("peer-alice"), + displayName = "alice", + message = message, + isRead = true + ) + runBlocking { repository.awaitPendingWrites() } + + val initialSnapshot = AtomicReference() + repository.initialize(initialSnapshot::set) + runBlocking { repository.awaitPendingWrites() } + assertEquals( + listOf(message), + initialSnapshot.get().chats.getValue("peer-alice") + ) + + val reloadedSnapshot = AtomicReference() + repository.reload(reloadedSnapshot::set) + runBlocking { repository.awaitPendingWrites() } + assertEquals( + listOf(message), + reloadedSnapshot.get().chats.getValue("peer-alice") + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt new file mode 100644 index 00000000..4b0ad570 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt @@ -0,0 +1,177 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.services.PrivateMessageArrivalOrder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import java.util.Date + +class ConversationSummaryTest { + @After + fun tearDown() { + PrivateMessageArrivalOrder.clear() + } + + @Test + fun `read offline conversation remains present`() { + val message = incoming("message", "alice", 100L) + PrivateMessageArrivalOrder.record(message.id) + + val conversations = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf("alice-peer" to listOf(message)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ) + + assertEquals(1, conversations.size) + assertEquals("alice-peer", conversations.single().conversationID) + assertEquals(0, conversations.single().unreadCount) + assertFalse(conversations.single().isConnected) + } + + @Test + fun `canonical aliases yield one conversation with unique messages`() { + val first = incoming("first", "alice", 300L) + val second = incoming("second", "alice", 100L) + PrivateMessageArrivalOrder.record(first.id) + PrivateMessageArrivalOrder.record(second.id) + + val conversations = buildConversationSummaries( + unreadConversationIDs = setOf("mesh-alias"), + privateChats = mapOf( + "mesh-alias" to listOf(first), + "nostr_alias" to listOf(first, second) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { "contact_alice" }, + isMessageRead = { false } + ) + + assertEquals(1, conversations.size) + assertEquals(2, conversations.single().unreadCount) + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + conversations.single().identityAliases + ) + assertEquals("alice", conversations.single().displayName) + assertEquals(DirectMessageTransport.NOSTR, conversations.single().transport) + } + + @Test + fun `online conversations sort before newer offline conversations`() { + val offline = summary( + id = "offline", + isConnected = false, + unreadCount = 3, + activity = 300L + ) + val onlineRead = summary( + id = "online-read", + isConnected = true, + unreadCount = 0, + activity = 100L + ) + val onlineUnread = summary( + id = "online-unread", + isConnected = true, + unreadCount = 1, + activity = 50L + ) + + assertEquals( + listOf("online-unread", "online-read", "offline"), + sortConversationSummaries(listOf(offline, onlineRead, onlineUnread)) + .map { it.conversationID } + ) + } + + @Test + fun `preview follows local arrival order instead of remote timestamp`() { + val first = incoming( + id = "first", + sender = "alice", + timestamp = 900L, + content = "older arrival" + ) + val second = incoming( + id = "second", + sender = "alice", + timestamp = 100L, + content = " latest\nmessage " + ) + PrivateMessageArrivalOrder.record(first.id) + PrivateMessageArrivalOrder.record(second.id) + + val conversation = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf("alice-peer" to listOf(first, second)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ).single() + + assertEquals("latest message", conversation.latestMessagePreview) + assertEquals(BitchatMessageType.Message, conversation.latestMessageType) + } + + @Test + fun `file preview contains filename without local path`() { + val message = incoming( + id = "file", + sender = "alice", + timestamp = 100L, + content = "/private/conversations/quarterly report.pdf", + type = BitchatMessageType.File + ) + + val conversation = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf("alice-peer" to listOf(message)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ).single() + + assertEquals("quarterly report.pdf", conversation.latestMessagePreview) + assertEquals(BitchatMessageType.File, conversation.latestMessageType) + } + + private fun incoming( + id: String, + sender: String, + timestamp: Long, + content: String = "hello", + type: BitchatMessageType = BitchatMessageType.Message + ) = BitchatMessage( + id = id, + sender = sender, + content = content, + type = type, + timestamp = Date(timestamp), + isPrivate = true + ) + + private fun summary( + id: String, + isConnected: Boolean, + unreadCount: Int, + activity: Long + ) = ConversationSummary( + conversationID = id, + displayName = id, + unreadCount = unreadCount, + latestMessageAt = activity, + latestActivityOrder = activity, + latestMessageType = BitchatMessageType.Message, + latestMessagePreview = "hello", + transport = DirectMessageTransport.MESH, + nostrPubkey = null, + identityAliases = setOf(id), + isConnected = isConnected + ) +} From c5ff1ca59aef99d982a49761897647c5e42a5aa6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:53:29 +0200 Subject: [PATCH 3/5] Complete persistent conversation lifecycle --- .../bitchat/android/services/AppStateStore.kt | 24 +++- .../services/ConversationRepository.kt | 20 ++- .../com/bitchat/android/ui/ChatViewModel.kt | 40 +++++- .../bitchat/android/ui/MeshPeerListSheet.kt | 120 +++++++++++++++--- .../android/services/AppStateStoreTest.kt | 29 +++++ .../services/ConversationRepositoryTest.kt | 32 +++++ 6 files changed, 227 insertions(+), 38 deletions(-) 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 6c78dec4..5691e275 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -37,6 +37,7 @@ object AppStateStore { @Volatile private var conversationRepository: ConversationRepository? = null + private var privateConversationWritesSuspended = false private val _nickname = MutableStateFlow("") val nickname: StateFlow = _nickname.asStateFlow() @@ -148,6 +149,7 @@ object AppStateStore { msg: BitchatMessage, forceRead: Boolean = false ): Boolean = synchronized(this) { + if (privateConversationWritesSuspended) return@synchronized false if (seenMessageIds.contains(msg.id)) return@synchronized false seenMessageIds.add(msg.id) PrivateMessageArrivalOrder.record(msg.id) @@ -202,6 +204,7 @@ object AppStateStore { fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) { synchronized(this) { + if (privateConversationWritesSuspended) return val map = _privateMessages.value.toMutableMap() var changed = false map.keys.toList().forEach { peer -> @@ -227,6 +230,7 @@ object AppStateStore { fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List) { if (keysToMerge.isEmpty()) return synchronized(this) { + if (privateConversationWritesSuspended) return val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID) val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID) .flatMap { key -> @@ -285,6 +289,7 @@ object AppStateStore { fun markPrivateMessageRead(messageID: String) { synchronized(this) { + if (privateConversationWritesSuspended) return if (messageID in _readPrivateMessageIDs.value) return _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID conversationRepository?.markRead(messageID) @@ -353,12 +358,24 @@ object AppStateStore { } } - fun clearPersistedPrivateConversations() { - synchronized(this) { - conversationRepository?.clearAll() + /** + * Atomically hides all conversations, rejects in-flight transport deliveries, then waits for + * every earlier database write and the panic wipe itself to finish. + */ + suspend fun panicClearPrivateConversations(): Boolean { + val repository = synchronized(this) { + privateConversationWritesSuspended = true _privateMessages.value = emptyMap() _readPrivateMessageIDs.value = emptySet() _selectedPrivateChatPeer.value = null + conversationRepository + } + return repository?.clearAllAndWait() ?: true + } + + fun resumePrivateConversationsAfterPanic() { + synchronized(this) { + privateConversationWritesSuspended = false } } @@ -406,6 +423,7 @@ object AppStateStore { private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) { synchronized(this) { + if (privateConversationWritesSuspended) return val liveChats = _privateMessages.value val liveMessageIDs = liveChats.values.flatten().map { it.id } PrivateMessageArrivalOrder.restore(snapshot.arrivalOrder, liveMessageIDs) diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt index 21ed56a4..d9513e6e 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -168,13 +168,19 @@ class ConversationRepository internal constructor( } } - fun clearAll() { - scope.launch { - try { - database.clearAll() - } catch (error: Exception) { - Log.e(TAG, "Unable to clear private conversations: ${error.message}") - } + /** + * Drains earlier writes and completes the database wipe before returning. + * + * Panic mode uses this stronger variant so identity regeneration and transport restart cannot + * race an outstanding message insert or an unfinished conversation deletion. + */ + suspend fun clearAllAndWait(): Boolean = withContext(dispatcher) { + try { + database.clearAll() + true + } catch (error: Exception) { + Log.e(TAG, "Unable to synchronously clear private conversations", error) + false } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 770b6151..b72ce65c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1119,8 +1119,22 @@ class ChatViewModel( } // MARK: - Emergency Clear - + + private var panicClearInProgress = false + fun panicClearAllData() { + if (panicClearInProgress) return + panicClearInProgress = true + viewModelScope.launch { + try { + performPanicClearAllData() + } finally { + panicClearInProgress = false + } + } + } + + private suspend fun performPanicClearAllData() { Log.w(TAG, "๐Ÿšจ PANIC MODE ACTIVATED - Clearing all sensitive data") try { com.bitchat.android.geohash.LocationChannelManager @@ -1131,9 +1145,15 @@ class ChatViewModel( // A pending one-shot downgrade confirmation must not survive panic or // become actionable against the fresh post-wipe identity. mediaSendingManager.clearPendingPrivateMediaConsent() - + + // Stop all message admission before wiping storage. The AppStateStore gate also rejects + // any transport callback already in flight until the fresh identity is ready. + clearAllMeshServiceData() + val conversationsCleared = + com.bitchat.android.services.AppStateStore + .panicClearPrivateConversations() + // Clear all UI managers - com.bitchat.android.services.AppStateStore.clearPersistedPrivateConversations() com.bitchat.android.services.AppStateStore.clear() messageManager.clearAllMessages() channelManager.clearAllChannels() @@ -1145,9 +1165,6 @@ class ChatViewModel( com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } - // Clear all mesh service data - clearAllMeshServiceData() - // Clear all cryptographic data clearAllCryptographicData() @@ -1179,8 +1196,17 @@ class ChatViewModel( val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - + + if (!conversationsCleared) { + // Privacy wins over availability: keep private-message admission and transports + // stopped if SQLite could not prove that the conversation history was erased. + Log.e(TAG, "๐Ÿšจ PANIC MODE INCOMPLETE - conversation database wipe failed") + return + } + // Recreate mesh service with fresh identity + com.bitchat.android.services.AppStateStore + .resumePrivateConversationsAfterPanic() recreateMeshServiceAfterPanic() Log.w(TAG, "๐Ÿšจ PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 8ea2254c..18035967 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -707,6 +707,9 @@ private fun DirectMessagesSection( val colorScheme = MaterialTheme.colorScheme val hapticFeedback = LocalHapticFeedback.current val deleteDescription = stringResource(R.string.delete_conversation_action) + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() Column(modifier = modifier) { SheetIconSectionHeader( @@ -730,6 +733,44 @@ private fun DirectMessagesSection( if (index > 0) SheetCardDivider() val dismissState = rememberSwipeToDismissBoxState() + val favoriteTargetID = + conversation.connectedPeerID ?: conversation.conversationID + val favoriteRelationship = remember( + conversation.identityAliases, + favoritePeers, + peerFavoritedUs + ) { + conversation.identityAliases + .asSequence() + .mapNotNull { alias -> + runCatching { + FavoritesPersistenceService.shared + .getFavoriteStatus(alias) + }.getOrNull() + } + .firstOrNull() + } + val fingerprint = conversation.connectedPeerID + ?.let(peerFingerprints::get) + ?: conversation.identityAliases + .asSequence() + .mapNotNull(peerFingerprints::get) + .firstOrNull() + ?: ContactIdentityResolver + .fingerprintFromContactConversationId( + conversation.conversationID + ) + ?: favoriteRelationship?.peerNoisePublicKey?.let { + ContactIdentityResolver.fingerprintHex(it) + } + val isFavorite = if (fingerprint != null) { + fingerprint in favoritePeers + } else { + viewModel.isFavorite(favoriteTargetID) + } + val theyFavoritedUs = + (fingerprint != null && fingerprint in peerFavoritedUs) || + favoriteRelationship?.theyFavoritedUs == true LaunchedEffect(dismissState.currentValue, conversation.conversationID) { if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) @@ -779,10 +820,15 @@ private fun DirectMessagesSection( directPeerIdentityIDs = directPeerIdentityIDs, wifiAwareIdentityIDs = wifiAwareIdentityIDs, viewModel = viewModel, + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, deleteDescription = deleteDescription, onClick = { onPrivateChatStart(conversation.conversationID) }, + onToggleFavorite = { + viewModel.toggleFavorite(favoriteTargetID) + }, onDeleteRequested = { onDeleteRequested(conversation) } @@ -800,8 +846,11 @@ private fun ConversationRow( directPeerIdentityIDs: Set, wifiAwareIdentityIDs: Set, viewModel: ChatViewModel, + isFavorite: Boolean, + theyFavoritedUs: Boolean, deleteDescription: String, onClick: () -> Unit, + onToggleFavorite: () -> Unit, onDeleteRequested: () -> Unit ) { val palette = LocalBitchatPalette.current @@ -930,30 +979,59 @@ private fun ConversationRow( ) } } - Text( - text = messagePreview, - fontFamily = BitchatFontFamily, - fontSize = 11.sp, - color = palette.textTertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = messagePreview, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = palette.textTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Text( + text = " ยท $relativeTime", + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + color = palette.textTertiary, + maxLines = 1 + ) + } } - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp) + UnreadBadge( + count = conversation.unreadCount, + colorScheme = colorScheme, + modifier = Modifier.padding(start = 4.dp) + ) + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center ) { - Text( - text = relativeTime, - fontFamily = BitchatFontFamily, - fontSize = 10.sp, - color = palette.textTertiary, - maxLines = 1 - ) - UnreadBadge( - count = conversation.unreadCount, - colorScheme = colorScheme + Icon( + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = stringResource( + if (isFavorite) { + R.string.cd_remove_favorite + } else { + R.string.cd_add_favorite + } + ), + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite || theyFavoritedUs) { + palette.accentOrange + } else { + palette.textTertiary + } ) } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index e095ea01..cd94e90d 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -8,16 +8,19 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import kotlinx.coroutines.runBlocking import java.util.Date class AppStateStoreTest { @Before fun setUp() { + AppStateStore.resumePrivateConversationsAfterPanic() AppStateStore.clear() } @After fun tearDown() { + AppStateStore.resumePrivateConversationsAfterPanic() AppStateStore.clear() } @@ -239,4 +242,30 @@ class AppStateStoreTest { assertEquals(1, AppStateStore.privateMessages.value.getValue("peer-a").size) assertTrue(AppStateStore.isPrivateMessageRead(message.id)) } + + @Test + fun `panic clear rejects private messages until explicitly resumed`() { + val beforePanic = BitchatMessage( + id = "before-panic", + sender = "alice", + content = "erase me", + timestamp = Date(1L), + isPrivate = true + ) + val duringPanic = beforePanic.copy(id = "during-panic") + val afterPanic = beforePanic.copy(id = "after-panic") + + assertTrue(AppStateStore.addPrivateMessage("peer-a", beforePanic)) + assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() }) + assertTrue(AppStateStore.privateMessages.value.isEmpty()) + assertFalse(AppStateStore.addPrivateMessage("peer-a", duringPanic)) + + AppStateStore.resumePrivateConversationsAfterPanic() + + assertTrue(AppStateStore.addPrivateMessage("peer-a", afterPanic)) + assertEquals( + listOf(afterPanic), + AppStateStore.privateMessages.value.getValue("peer-a") + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt index d0bba0db..c42eb9aa 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -74,4 +75,35 @@ class ConversationRepositoryTest { reloadedSnapshot.get().chats.getValue("peer-alice") ) } + + @Test + fun `panic clear drains queued writes and leaves database empty`() { + val repository = ConversationRepository( + context = context, + dispatcher = dispatcher, + databaseName = databaseName + ) + repository.upsertMessage( + conversationID = "peer-alice", + aliases = setOf("peer-alice"), + displayName = "alice", + message = BitchatMessage( + id = "queued-before-panic", + sender = "alice", + content = "must be erased", + timestamp = Date(100L), + isPrivate = true + ), + isRead = true + ) + + assertTrue(runBlocking { repository.clearAllAndWait() }) + + val snapshot = AtomicReference() + repository.reload(snapshot::set) + runBlocking { repository.awaitPendingWrites() } + assertTrue(snapshot.get().chats.isEmpty()) + assertTrue(snapshot.get().readMessageIDs.isEmpty()) + assertTrue(snapshot.get().deletedMessageIDs.isEmpty()) + } } From c8153c0d1886e9a5068ffbb9d973e8fa853509d4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:57:28 +0200 Subject: [PATCH 4/5] Keep Wear shared state compatible --- wear/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 03ae1431..e2d4abc1 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -77,6 +77,7 @@ val sharedSourceIncludes = listOf( "com/bitchat/android/services/AppStateStore.kt", "com/bitchat/android/services/ContactDirectory.kt", "com/bitchat/android/services/ContactIdentityResolver.kt", + "com/bitchat/android/services/ConversationRepository.kt", "com/bitchat/android/services/PrivateMessageArrivalOrder.kt", "com/bitchat/android/services/SeenMessageStore.kt", "com/bitchat/android/services/VerificationService.kt", From bf87a80617e191ee1effc2e19fb68eda3496d7fa Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:00:24 +0200 Subject: [PATCH 5/5] Honor private message admission during panic --- .../android/mesh/BluetoothMeshService.kt | 22 ++---- .../java/com/bitchat/android/mesh/MeshCore.kt | 8 ++- .../services/IncomingMessageAdmission.kt | 36 ++++++++++ .../wifi-aware/WifiAwareMeshService.kt | 23 +++---- .../services/IncomingMessageAdmissionTest.kt | 68 +++++++++++++++++++ .../com/bitchat/watch/mesh/WearMeshService.kt | 31 ++++++--- 6 files changed, 145 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index f828af9e..a05f704e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -480,21 +480,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // 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) { } + // Private-message admission is authoritative. In particular, do not forward a + // callback or notify after panic mode rejected the message while wiping state. + if ( + !com.bitchat.android.services.IncomingMessageAdmission + .admitToAppState(message) + ) return + // And forward to UI delegate if attached delegate?.didReceiveMessage(message) diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index ae1f535a..093e3766 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -41,7 +41,11 @@ class MeshCore( private val hooks: Hooks = Hooks() ) { data class Hooks( - val onMessageReceived: ((BitchatMessage) -> Unit)? = null, + /** + * Reflects a decoded message into transport-owned state before delegate dispatch. + * Return false to suppress all downstream effects for a rejected message. + */ + val onMessageReceived: ((BitchatMessage) -> Boolean)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val onReadReceiptSent: ((String) -> Unit)? = null, @@ -392,7 +396,7 @@ class MeshCore( } override fun onMessageReceived(message: BitchatMessage) { - hooks.onMessageReceived?.invoke(message) + if (hooks.onMessageReceived?.invoke(message) == false) return delegate?.didReceiveMessage(message) } diff --git a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt new file mode 100644 index 00000000..addc02c0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt @@ -0,0 +1,36 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage + +/** + * Reflects an incoming transport message into process-wide state before any downstream effects. + * + * Private-message admission is authoritative: a duplicate or a message rejected while panic mode + * is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications. + * Public and channel messages retain their existing best-effort behavior if state reflection fails. + */ +internal object IncomingMessageAdmission { + fun admitToAppState(message: BitchatMessage): Boolean = try { + when { + message.isPrivate -> { + val peerID = message.senderPeerID?.takeIf(String::isNotBlank) + ?: return false + AppStateStore.addPrivateMessage(peerID, message) + } + + message.channel != null -> { + AppStateStore.addChannelMessage(message.channel, message) + true + } + + else -> { + AppStateStore.addPublicMessage(message) + true + } + } + } catch (_: Exception) { + // Preserve the pre-existing best-effort dispatch for public/channel messages, but never + // bypass private-message admission when persistence or canonicalization fails. + !message.isPrivate + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 85669aba..479a1db6 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -188,21 +188,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG) } - private fun handleMessageReceived(message: BitchatMessage) { - 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) { } + private fun handleMessageReceived(message: BitchatMessage): Boolean { + // Match BLE admission semantics: a private message rejected during panic or as a + // duplicate must not create a notification after the conversation state was cleared. + if ( + !com.bitchat.android.services.IncomingMessageAdmission + .admitToAppState(message) + ) return false if (delegate == null && message.isPrivate) { try { @@ -215,6 +207,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } } catch (_: Exception) { } } + return true } /** diff --git a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt new file mode 100644 index 00000000..ec471580 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +class IncomingMessageAdmissionTest { + @Before + fun setUp() { + AppStateStore.resumePrivateConversationsAfterPanic() + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.resumePrivateConversationsAfterPanic() + AppStateStore.clear() + } + + @Test + fun `private message rejected during panic cannot continue transport dispatch`() { + assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() }) + + assertFalse( + IncomingMessageAdmission.admitToAppState( + privateMessage(id = "during-panic") + ) + ) + assertTrue(AppStateStore.privateMessages.value.isEmpty()) + } + + @Test + fun `duplicate private transport delivery is rejected before downstream effects`() { + val message = privateMessage(id = "same-message-over-two-transports") + + assertTrue(IncomingMessageAdmission.admitToAppState(message)) + assertFalse(IncomingMessageAdmission.admitToAppState(message)) + } + + @Test + fun `public and channel messages preserve best effort admission`() { + val public = BitchatMessage( + id = "public", + sender = "alice", + content = "hello", + timestamp = Date(1L) + ) + val channel = public.copy(id = "channel", channel = "#mesh") + + assertTrue(IncomingMessageAdmission.admitToAppState(public)) + assertTrue(IncomingMessageAdmission.admitToAppState(channel)) + assertTrue(AppStateStore.publicMessages.value.contains(public)) + } + + private fun privateMessage(id: String) = BitchatMessage( + id = id, + sender = "alice", + content = "secret", + timestamp = Date(1L), + isPrivate = true, + senderPeerID = "peer-a" + ) +} diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index 71d707f1..66dded9d 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -227,18 +227,27 @@ class WearMeshService private constructor(private val context: Context) { } } - private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) { - try { - when { - message.isPrivate -> { - val peer = message.senderPeerID ?: return - AppStateStore.addPrivateMessage(peer, message) - try { onPrivateMessage?.invoke(message) } catch (_: Exception) { } - } - message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message) - else -> AppStateStore.addPublicMessage(message) + private fun handleMessageReceived( + message: com.bitchat.android.model.BitchatMessage + ): Boolean = try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: return false + if (!AppStateStore.addPrivateMessage(peer, message)) return false + try { onPrivateMessage?.invoke(message) } catch (_: Exception) { } + true } - } catch (_: Exception) { } + message.channel != null -> { + AppStateStore.addChannelMessage(message.channel, message) + true + } + else -> { + AppStateStore.addPublicMessage(message) + true + } + } + } catch (_: Exception) { + !message.isPrivate } fun startServices() {