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] 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)) + } }