From e245fee31629c780bdef61835a9d18e5b226a4dd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:46:21 +0200 Subject: [PATCH] Polish persistent private conversations --- app/src/main/AndroidManifest.xml | 5 + .../android/features/file/FileUtils.kt | 26 + .../identity/SecureIdentityStateManager.kt | 7 + .../nostr/NostrDirectMessageHandler.kt | 33 +- .../ConversationNotificationReceiver.kt | 84 ++ .../bitchat/android/services/AppStateStore.kt | 370 ++++++- .../services/ConversationListPreferences.kt | 164 +++ .../services/ConversationRepository.kt | 965 ++++++++++++++++-- .../services/ConversationStorageCipher.kt | 98 ++ .../services/IncomingMessageAdmission.kt | 8 +- .../services/PrivateMessageArrivalOrder.kt | 33 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 17 +- .../com/bitchat/android/ui/ChatViewModel.kt | 274 ++++- .../bitchat/android/ui/CommandProcessor.kt | 71 +- .../bitchat/android/ui/ConversationSummary.kt | 53 +- .../bitchat/android/ui/MediaSendingManager.kt | 22 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 914 +++++++++++++---- .../com/bitchat/android/ui/MessageManager.kt | 57 +- .../bitchat/android/ui/NotificationManager.kt | 227 +++- .../bitchat/android/ui/PrivateChatManager.kt | 89 ++ app/src/main/res/values/strings.xml | 47 +- .../nostr/NostrDirectMessageHandlerTest.kt | 17 + .../android/services/AppStateStoreTest.kt | 62 ++ .../services/ConversationDatabaseTest.kt | 337 +++++- .../ConversationListPreferencesTest.kt | 75 ++ .../services/ConversationRepositoryTest.kt | 12 +- .../InMemoryConversationStorageCipher.kt | 39 + .../services/IncomingMessageAdmissionTest.kt | 145 +++ .../android/ui/ConversationSummaryTest.kt | 41 + .../ui/MediaSendingManagerMigrationTest.kt | 33 +- 30 files changed, 3865 insertions(+), 460 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt create mode 100644 app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt create mode 100644 app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0c71b94c..44740452 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -137,6 +137,11 @@ + + ) { + val roots = listOf(context.filesDir, context.cacheDir) + .mapNotNull { runCatching { it.canonicalFile }.getOrNull() } + paths.asSequence() + .mapNotNull { runCatching { File(it).canonicalFile }.getOrNull() } + .distinctBy(File::getPath) + .filter { file -> + roots.any { root -> + file.path == root.path || + file.path.startsWith(root.path + File.separator) + } + } + .forEach { file -> + runCatching { + if (file.isFile && !file.delete()) { + Log.w(TAG, "Unable to delete unreferenced conversation media") + } + } + } + } } diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index ab00b137..1efa87a2 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -528,4 +528,11 @@ class SecureIdentityStateManager { } editor.apply() } + + /** Use for panic paths that must finish the disk mutation before identity reset continues. */ + fun clearSecureValuesSynchronously(vararg keys: String): Boolean { + val editor = prefs.edit() + keys.forEach(editor::remove) + return editor.commit() + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 2fdc3180..fffcdab7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -132,7 +132,14 @@ class NostrDirectMessageHandler( val favoriteControl = FavoriteControlMessage.parse(pm.content) if (favoriteControl != null) { - handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey) + val admitted = handleFavoriteControl( + favoriteControl, + conversationID, + senderNickname, + timestamp, + senderPubkey + ) + if (!admitted) return if (!seenStore.hasDelivered(pm.messageID)) { val nostrTransport = NostrTransport.getInstance(application) nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) @@ -157,13 +164,14 @@ class NostrDirectMessageHandler( val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID) - withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage( + val admitted = withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessageDurably( message = message, suppressUnread = suppressUnread, origin = PrivateMessageOrigin.NOSTR ) } + if (!admitted) return if (!seenStore.hasDelivered(pm.messageID)) { val nostrTransport = NostrTransport.getInstance(application) @@ -215,13 +223,19 @@ class NostrDirectMessageHandler( senderNostrPubkey = senderPubkey ) Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") - withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage( + val admitted = withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessageDurably( message = message, suppressUnread = false, origin = PrivateMessageOrigin.NOSTR ) } + if (!admitted) { + com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths( + application, + listOf(savedPath) + ) + } } else { Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID") } @@ -238,15 +252,15 @@ class NostrDirectMessageHandler( senderNickname: String, timestamp: Date, senderPubkey: String - ) { - try { + ): Boolean { + return try { val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey) val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) } ?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey) if (noiseKey == null) { Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...") - return + return false } FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite) @@ -278,7 +292,7 @@ class NostrDirectMessageHandler( ) withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage( + privateChatManager.handleIncomingPrivateMessageDurably( message = systemMessage, suppressUnread = true, origin = PrivateMessageOrigin.NOSTR @@ -286,6 +300,7 @@ class NostrDirectMessageHandler( } } catch (e: Exception) { Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}") + false } } diff --git a/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt new file mode 100644 index 00000000..e6dbef2a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt @@ -0,0 +1,84 @@ +package com.bitchat.android.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.RemoteInput +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.MessageRouter +import com.bitchat.android.ui.NotificationManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.Date +import java.util.UUID + +/** Handles privacy-scoped direct reply and mark-read actions from DM notifications. */ +class ConversationNotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val conversationID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID) + ?.let(ContactDirectory::canonicalConversationId) + ?: return + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + var acknowledged = false + when (intent.action) { + NotificationManager.ACTION_MARK_CONVERSATION_READ -> { + acknowledged = + AppStateStore.setPrivateConversationRead(conversationID, true) + } + + NotificationManager.ACTION_REPLY_TO_CONVERSATION -> { + val reply = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(NotificationManager.KEY_TEXT_REPLY) + ?.toString() + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: return@launch + val mesh = MeshServiceHolder.getUnifiedOrCreate( + context.applicationContext + ) + val message = BitchatMessage( + id = UUID.randomUUID().toString().uppercase(), + sender = mesh.myPeerID, + content = reply, + timestamp = Date(), + isPrivate = true, + recipientNickname = intent.getStringExtra( + NotificationManager.EXTRA_SENDER_NICKNAME + ), + senderPeerID = mesh.myPeerID, + deliveryStatus = DeliveryStatus.Sending + ) + val persisted = AppStateStore.addPrivateMessageDurably( + peerID = conversationID, + msg = message, + forceRead = true + ) + if (persisted) { + MessageRouter.getInstance(context.applicationContext, mesh) + .sendPrivate( + content = reply, + toPeerID = conversationID, + recipientNickname = message.recipientNickname.orEmpty(), + messageID = message.id + ) + acknowledged = + AppStateStore.setPrivateConversationRead(conversationID, true) + } + } + } + if (acknowledged) { + NotificationManager.acknowledgeConversation(context, conversationID) + } + } finally { + pendingResult.finish() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 5691e275..056e1524 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() + private val reservedPrivateMessageIds = mutableSetOf() private val seenPublicMessageKeys = mutableSetOf() private val peerIdsByTransport = mutableMapOf>() private var privateWritesSinceGlobalPrune = 0 @@ -34,10 +35,14 @@ object AppStateStore { val privateMessages: StateFlow>> = _privateMessages.asStateFlow() private val _readPrivateMessageIDs = MutableStateFlow>(emptySet()) val readPrivateMessageIDs: StateFlow> = _readPrivateMessageIDs.asStateFlow() + private val _unreadPrivateMessageCounts = MutableStateFlow>(emptyMap()) + val unreadPrivateMessageCounts: StateFlow> = + _unreadPrivateMessageCounts.asStateFlow() @Volatile private var conversationRepository: ConversationRepository? = null private var privateConversationWritesSuspended = false + private var privateConversationGeneration = 0L private val _nickname = MutableStateFlow("") val nickname: StateFlow = _nickname.asStateFlow() @@ -79,10 +84,53 @@ object AppStateStore { repository.reload(::restorePrivateConversations) } + internal fun setConversationRepositoryForTest(repository: ConversationRepository?) { + conversationRepository = repository + } + suspend fun awaitConversationPersistence() { conversationRepository?.awaitPendingWrites() } + suspend fun loadPrivateConversationHistory(conversationID: String): Boolean { + val repository = conversationRepository ?: return false + val snapshot = repository.loadConversationAndWait( + ContactDirectory.canonicalConversationId(conversationID) + ) ?: return false + restorePrivateConversations(snapshot) + return true + } + + /** + * Drops an opened conversation's full payloads from memory while retaining its summary row. + * The complete bounded history remains encrypted in SQLite and is loaded again on demand. + */ + fun releasePrivateConversationHistory(conversationID: String) { + synchronized(this) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val matching = _privateMessages.value.entries.filter { (id, _) -> + ContactDirectory.canonicalConversationId(id) + .equals(canonicalID, ignoreCase = true) + } + val latest = matching + .flatMap { it.value } + .distinctBy { it.id } + .maxWithOrNull( + compareBy { + PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MIN_VALUE + }.thenBy { it.timestamp.time } + ) + ?: return + val compacted = _privateMessages.value.toMutableMap() + matching.forEach { compacted.remove(it.key) } + compacted[canonicalID] = listOf(latest) + _privateMessages.value = compacted + } + } + + val conversationStoreState: StateFlow + get() = conversationRepository?.storeState ?: EMPTY_CONVERSATION_STORE_STATE + fun setTransportPeers(transportId: String, ids: List) { synchronized(this) { peerIdsByTransport[transportId] = ids.toSet() @@ -149,8 +197,64 @@ object AppStateStore { msg: BitchatMessage, forceRead: Boolean = false ): Boolean = synchronized(this) { - if (privateConversationWritesSuspended) return@synchronized false - if (seenMessageIds.contains(msg.id)) return@synchronized false + addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true) + } + + /** + * Persists an incoming private message before it is admitted to UI, unread, haptic, or + * notification state. Transport callbacks invoke this from their background worker. + */ + suspend fun addPrivateMessageDurably( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean = false + ): Boolean { + val persistence = synchronized(this) { + if (privateConversationWritesSuspended) return false + if (seenMessageIds.contains(msg.id) || !reservedPrivateMessageIds.add(msg.id)) { + return false + } + privateMessagePersistence(peerID, msg, forceRead) + } + val repository = persistence.repository + if (repository == null) { + synchronized(this) { reservedPrivateMessageIds.remove(msg.id) } + return false + } + val persisted = repository.upsertMessageAndWait( + conversationID = persistence.conversationID, + aliases = persistence.aliases, + displayName = persistence.displayName, + message = msg, + isRead = persistence.isRead + ) + return synchronized(this) { + reservedPrivateMessageIds.remove(msg.id) + if ( + !persisted || + privateConversationWritesSuspended || + persistence.generation != privateConversationGeneration || + seenMessageIds.contains(msg.id) + ) { + return@synchronized false + } + addPrivateMessageLocked( + peerID = peerID, + msg = msg, + forceRead = forceRead, + persistAsynchronously = false + ) + } + } + + private fun addPrivateMessageLocked( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean, + persistAsynchronously: Boolean + ): Boolean { + if (privateConversationWritesSuspended) return false + if (seenMessageIds.contains(msg.id)) return false seenMessageIds.add(msg.id) PrivateMessageArrivalOrder.record(msg.id) val conversationID = ContactDirectory.canonicalConversationId(peerID) @@ -167,6 +271,10 @@ object AppStateStore { ?.equals(conversationID, ignoreCase = true) == true if (isRead) { _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id + } else { + val counts = _unreadPrivateMessageCounts.value.toMutableMap() + counts[conversationID] = (counts[conversationID] ?: 0) + 1 + _unreadPrivateMessageCounts.value = counts } val aliases = runCatching { ContactDirectory.aliasesForConversation(peerID) + @@ -177,15 +285,53 @@ object AppStateStore { ?: msg.sender.takeUnless { it.isBlank() || it == "system" || it == _nickname.value } - conversationRepository?.upsertMessage( + if (persistAsynchronously) { + conversationRepository?.upsertMessage( + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + message = msg, + isRead = isRead + ) + } + prunePrivateMessagesLocked(conversationID) + return true + } + + private fun privateMessagePersistence( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean + ): PendingPrivateMessagePersistence { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val existingMessages = _privateMessages.value[conversationID].orEmpty() + val isRead = forceRead || + msg.sender == "system" || + msg.sender == _nickname.value || + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(conversationID, ignoreCase = true) == true + val aliases = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.aliasesForConversation(conversationID) + + listOfNotNull(msg.senderPeerID) + }.getOrDefault(setOf(peerID, conversationID)) + val displayName = ContactDirectory.resolve(conversationID).displayName + ?: (existingMessages + msg) + .lastOrNull { candidate -> + candidate.sender.isNotBlank() && + candidate.sender != "system" && + candidate.sender != _nickname.value + } + ?.sender + return PendingPrivateMessagePersistence( + repository = conversationRepository, conversationID = conversationID, aliases = aliases, displayName = displayName, - message = msg, - isRead = isRead + isRead = isRead, + generation = privateConversationGeneration ) - prunePrivateMessagesLocked(conversationID) - true } fun hasSeenMessage(messageID: String): Boolean = synchronized(this) { @@ -213,7 +359,14 @@ object AppStateStore { if (idx >= 0) { val current = list[idx].deliveryStatus // Do not downgrade (e.g., Read -> Delivered) - if (statusPriority(status) >= statusPriority(current)) { + val mayReplace = when { + status is DeliveryStatus.Failed -> + current !is DeliveryStatus.Delivered && + current !is DeliveryStatus.Read + current is DeliveryStatus.Failed -> true + else -> statusPriority(status) >= statusPriority(current) + } + if (mayReplace) { list[idx] = list[idx].copy(deliveryStatus = status) map[peer] = list changed = true @@ -292,6 +445,17 @@ object AppStateStore { if (privateConversationWritesSuspended) return if (messageID in _readPrivateMessageIDs.value) return _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID + val conversationID = _privateMessages.value.entries + .firstOrNull { (_, messages) -> messages.any { it.id == messageID } } + ?.key + if (conversationID != null) { + val counts = _unreadPrivateMessageCounts.value.toMutableMap() + val remaining = ((counts[conversationID] ?: 0) - 1).coerceAtLeast(0) + if (remaining == 0) counts.remove(conversationID) else { + counts[conversationID] = remaining + } + _unreadPrivateMessageCounts.value = counts + } conversationRepository?.markRead(messageID) } } @@ -299,6 +463,39 @@ object AppStateStore { fun isPrivateMessageRead(messageID: String): Boolean = messageID in _readPrivateMessageIDs.value + suspend fun setPrivateConversationRead( + conversationID: String, + isRead: Boolean + ): Boolean { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val repository = conversationRepository ?: return false + val result = repository.setConversationReadAndWait(canonicalID, isRead) + if (!result.success) return false + synchronized(this) { + val messageIDs = _privateMessages.value + .filterKeys { key -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + .values + .flatten() + .mapTo(linkedSetOf(), BitchatMessage::id) + if (isRead) { + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageIDs + _unreadPrivateMessageCounts.value = + _unreadPrivateMessageCounts.value - canonicalID + } else { + result.affectedMessageID?.let { latestMessageID -> + _readPrivateMessageIDs.value = + _readPrivateMessageIDs.value - latestMessageID + _unreadPrivateMessageCounts.value = + _unreadPrivateMessageCounts.value + (canonicalID to 1) + } + } + } + return true + } + fun deletePrivateConversation(peerOrConversationID: String): Set { synchronized(this) { val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) @@ -324,6 +521,8 @@ object AppStateStore { matchingKeys.forEach(updated::remove) _privateMessages.value = updated _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs + _unreadPrivateMessageCounts.value = + _unreadPrivateMessageCounts.value - matchingKeys - canonicalID if ( _selectedPrivateChatPeer.value ?.let(ContactDirectory::canonicalConversationId) @@ -335,6 +534,115 @@ object AppStateStore { } } + internal suspend fun deletePrivateConversationAndWait( + peerOrConversationID: String + ): DeletedPrivateConversation? { + loadPrivateConversationHistory(peerOrConversationID) + val deletion = synchronized(this) { + if (privateConversationWritesSuspended) return null + buildDeletedConversationLocked(peerOrConversationID) + } + val repository = conversationRepository ?: return null + if (!repository.deleteConversationAndWait(deletion.conversationID, deletion.aliases)) { + return null + } + synchronized(this) { + val updated = _privateMessages.value.toMutableMap() + updated.keys.toList().forEach { key -> + if ( + ContactDirectory.canonicalConversationId(key) + .equals(deletion.conversationID, ignoreCase = true) + ) { + val remaining = updated[key].orEmpty().filterNot { + it.id in deletion.messageIDs + } + if (remaining.isEmpty()) updated.remove(key) else updated[key] = remaining + } + } + _privateMessages.value = updated + _readPrivateMessageIDs.value = + _readPrivateMessageIDs.value - deletion.messageIDs + val counts = _unreadPrivateMessageCounts.value.toMutableMap() + val currentCount = counts[deletion.conversationID] ?: 0 + val remainingUnread = (currentCount - deletion.unreadMessageCount).coerceAtLeast(0) + if (remainingUnread == 0) counts.remove(deletion.conversationID) else { + counts[deletion.conversationID] = remainingUnread + } + _unreadPrivateMessageCounts.value = counts + if ( + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(deletion.conversationID, ignoreCase = true) == true + ) { + _selectedPrivateChatPeer.value = null + } + } + return deletion + } + + internal suspend fun restoreDeletedConversation( + deletion: DeletedPrivateConversation + ): Boolean { + val repository = conversationRepository ?: return false + if ( + !repository.restoreConversationAndWait( + conversationID = deletion.conversationID, + aliases = deletion.aliases, + displayName = deletion.displayName, + messages = deletion.messages, + readMessageIDs = deletion.readMessageIDs + ) + ) { + return false + } + synchronized(this) { + seenMessageIds.removeAll(deletion.messageIDs) + deletion.messages.forEach { message -> + addPrivateMessageLocked( + peerID = deletion.conversationID, + msg = message, + forceRead = message.id in deletion.readMessageIDs, + persistAsynchronously = false + ) + } + } + return true + } + + private fun buildDeletedConversationLocked( + peerOrConversationID: String + ): DeletedPrivateConversation { + 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 messages = matchingKeys + .flatMap { _privateMessages.value[it].orEmpty() } + .distinctBy(BitchatMessage::id) + val messageIDs = messages.mapTo(linkedSetOf(), BitchatMessage::id) + val readIDs = _readPrivateMessageIDs.value.intersect(messageIDs) + return DeletedPrivateConversation( + conversationID = canonicalID, + aliases = aliases, + displayName = ContactDirectory.resolve(canonicalID).displayName, + messages = messages, + readMessageIDs = readIDs, + unreadMessageCount = messages.count { message -> + message.id !in readIDs && + message.sender != "system" && + message.sender != _nickname.value + } + ) + } + fun removePrivateMessage(messageID: String) { synchronized(this) { val updated = _privateMessages.value.toMutableMap() @@ -365,8 +673,10 @@ object AppStateStore { suspend fun panicClearPrivateConversations(): Boolean { val repository = synchronized(this) { privateConversationWritesSuspended = true + privateConversationGeneration += 1 _privateMessages.value = emptyMap() _readPrivateMessageIDs.value = emptySet() + _unreadPrivateMessageCounts.value = emptyMap() _selectedPrivateChatPeer.value = null conversationRepository } @@ -394,6 +704,8 @@ object AppStateStore { fun clear() { synchronized(this) { seenMessageIds.clear() + reservedPrivateMessageIds.clear() + privateConversationGeneration += 1 seenPublicMessageKeys.clear() PrivateMessageArrivalOrder.clear() privateWritesSinceGlobalPrune = 0 @@ -404,6 +716,7 @@ object AppStateStore { _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _readPrivateMessageIDs.value = emptySet() + _unreadPrivateMessageCounts.value = emptyMap() _channelMessages.value = emptyMap() _nickname.value = "" _selectedPrivateChatPeer.value = null @@ -421,12 +734,17 @@ object AppStateStore { ).joinToString("\u001F") } - private fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) { + internal 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) + PrivateMessageArrivalOrder.restore( + snapshot.arrivalOrder, + liveMessageIDs, + snapshot.receivedAtByMessageID, + snapshot.arrivalSequenceByMessageID + ) val merged = linkedMapOf>() snapshot.chats.forEach { (conversationID, messages) -> @@ -451,6 +769,12 @@ object AppStateStore { _readPrivateMessageIDs.value = (snapshot.readMessageIDs + _readPrivateMessageIDs.value) - snapshot.deletedMessageIDs + val unreadCounts = _unreadPrivateMessageCounts.value.toMutableMap() + snapshot.unreadCounts.forEach { (conversationID, count) -> + if (count > 0) unreadCounts[conversationID] = count + else unreadCounts.remove(conversationID) + } + _unreadPrivateMessageCounts.value = unreadCounts _privateMessages.value = ContactDirectory.canonicalizePrivateChats( merged.mapValues { (_, messages) -> PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) @@ -565,3 +889,29 @@ object AppStateStore { it.toByteArray(Charsets.UTF_8).size } } + +private data class PendingPrivateMessagePersistence( + val repository: ConversationRepository?, + val conversationID: String, + val aliases: Set, + val displayName: String?, + val isRead: Boolean, + val generation: Long +) + +internal data class DeletedPrivateConversation( + val conversationID: String, + val aliases: Set, + val displayName: String?, + val messages: List, + val readMessageIDs: Set, + val unreadMessageCount: Int, + val wasPinned: Boolean = false, + val wasMuted: Boolean = false, + val draft: String? = null +) { + val messageIDs: Set = messages.mapTo(linkedSetOf(), BitchatMessage::id) +} + +private val EMPTY_CONVERSATION_STORE_STATE = + MutableStateFlow(ConversationStoreState.Ready).asStateFlow() diff --git a/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt new file mode 100644 index 00000000..c9ab4945 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt @@ -0,0 +1,164 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject + +/** + * Small encrypted, immediately observable preferences for conversation-list organization. + * + * Message history remains in SQLite; these compact sets and drafts belong in the app's existing + * Keystore-backed preference store. Panic clearing the identity store also removes these values. + */ +internal class ConversationListPreferences private constructor( + private val stateManager: SecureIdentityStateManager +) { + private constructor(context: Context) : this( + SecureIdentityStateManager(context.applicationContext) + ) + + internal constructor( + stateManager: SecureIdentityStateManager, + testOnly: Boolean + ) : this(stateManager) { + require(testOnly) { "Injected conversation preferences are test-only" } + } + + companion object { + private const val PINNED_KEY = "conversation_pinned_v1" + private const val MUTED_KEY = "conversation_muted_v1" + private const val DRAFTS_KEY = "conversation_drafts_v1" + private const val MAX_DRAFT_CHARS = 8_000 + private const val MAX_DRAFTS = 50 + private const val MAX_DRAFT_CHARS_TOTAL = 128_000 + + @Volatile + private var instance: ConversationListPreferences? = null + + fun getInstance(context: Context): ConversationListPreferences = + instance ?: synchronized(this) { + instance ?: ConversationListPreferences(context.applicationContext).also { + instance = it + } + } + } + + private val _pinned = MutableStateFlow(loadSet(PINNED_KEY)) + val pinned: StateFlow> = _pinned.asStateFlow() + private val _muted = MutableStateFlow(loadSet(MUTED_KEY)) + val muted: StateFlow> = _muted.asStateFlow() + private val _drafts = MutableStateFlow(loadDrafts()) + val drafts: StateFlow> = _drafts.asStateFlow() + + fun togglePinned(conversationID: String) { + _pinned.value = _pinned.value.toggle(normalize(conversationID)) + saveSet(PINNED_KEY, _pinned.value) + } + + fun toggleMuted(conversationID: String) { + _muted.value = _muted.value.toggle(normalize(conversationID)) + saveSet(MUTED_KEY, _muted.value) + } + + fun isMuted(conversationID: String): Boolean = + normalize(conversationID) in _muted.value + + fun isPinned(conversationID: String): Boolean = + normalize(conversationID) in _pinned.value + + fun draftFor(conversationID: String): String? = + _drafts.value[normalize(conversationID)] + + fun setDraft(conversationID: String, text: String) { + val key = normalize(conversationID) + val updated = _drafts.value.toMutableMap() + // Reinsert edited drafts at the end so bounded eviction approximates least-recently-used. + updated.remove(key) + val bounded = text.take(MAX_DRAFT_CHARS) + if (bounded.isNotBlank()) updated[key] = bounded + val retained = boundDrafts(updated) + _drafts.value = retained + saveDrafts(retained) + } + + fun removeConversation(conversationID: String) { + val key = normalize(conversationID) + _pinned.value = _pinned.value - key + _muted.value = _muted.value - key + _drafts.value = _drafts.value - key + saveSet(PINNED_KEY, _pinned.value) + saveSet(MUTED_KEY, _muted.value) + saveDrafts(_drafts.value) + } + + fun clearInMemory() { + _pinned.value = emptySet() + _muted.value = emptySet() + _drafts.value = emptyMap() + } + + fun clearAll(): Boolean { + val cleared = stateManager.clearSecureValuesSynchronously( + PINNED_KEY, + MUTED_KEY, + DRAFTS_KEY + ) + clearInMemory() + return cleared + } + + private fun loadSet(key: String): Set = runCatching { + val array = JSONArray(stateManager.getSecureValue(key) ?: return emptySet()) + buildSet { + for (index in 0 until array.length()) add(normalize(array.getString(index))) + } + }.getOrDefault(emptySet()) + + private fun saveSet(key: String, values: Set) { + stateManager.storeSecureValue(key, JSONArray(values.sorted()).toString()) + } + + private fun loadDrafts(): Map = runCatching { + val json = JSONObject(stateManager.getSecureValue(DRAFTS_KEY) ?: return emptyMap()) + val loaded = buildMap { + json.keys().forEach { key -> + json.optString(key).takeIf(String::isNotBlank)?.let { + put(normalize(key), it.take(MAX_DRAFT_CHARS)) + } + } + } + boundDrafts(loaded) + }.getOrDefault(emptyMap()) + + private fun saveDrafts(values: Map) { + stateManager.storeSecureValue( + DRAFTS_KEY, + JSONObject().apply { + values.forEach { (key, value) -> put(key, value) } + }.toString() + ) + } + + private fun Set.toggle(value: String): Set = + if (value in this) this - value else this + value + + private fun normalize(value: String): String = + ContactDirectory.canonicalConversationId(value).lowercase() + + private fun boundDrafts(values: Map): Map { + val retained = LinkedHashMap(values) + while ( + retained.size > MAX_DRAFTS || + retained.values.sumOf(String::length) > MAX_DRAFT_CHARS_TOTAL + ) { + val oldest = retained.keys.firstOrNull() ?: break + retained.remove(oldest) + } + return retained + } + +} 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 d9513e6e..a4b3765c 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -5,6 +5,7 @@ import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import android.util.Base64 import android.util.Log import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType @@ -13,9 +14,15 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest import java.util.Date import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean @@ -35,7 +42,8 @@ class ConversationRepository internal constructor( Thread(runnable, "conversation-store").apply { isDaemon = true } } .asCoroutineDispatcher(), - databaseName: String = ConversationDatabase.DEFAULT_DATABASE_NAME + databaseName: String = ConversationDatabase.DEFAULT_DATABASE_NAME, + storageCipher: ConversationStorageCipher = AndroidConversationStorageCipher() ) { companion object { private const val TAG = "ConversationRepository" @@ -53,12 +61,17 @@ class ConversationRepository internal constructor( fun tryGetInstance(): ConversationRepository? = instance } + private val applicationContext = context.applicationContext private val database = ConversationDatabase( - context = context.applicationContext, - databaseName = databaseName + context = applicationContext, + databaseName = databaseName, + storageCipher = storageCipher ) private val scope = CoroutineScope(SupervisorJob() + dispatcher) private val initialized = AtomicBoolean(false) + private val _storeState = + MutableStateFlow(ConversationStoreState.Loading) + val storeState: StateFlow = _storeState.asStateFlow() internal fun initialize(onLoaded: (PersistedConversationSnapshot) -> Unit) { if (!initialized.compareAndSet(false, true)) return @@ -81,10 +94,16 @@ class ConversationRepository internal constructor( ) { scope.launch { try { - if (pruneFirst) database.pruneToRetentionLimits() - onLoaded(database.loadSnapshot()) + if (pruneFirst) { + deleteStoredMedia(database.pruneToRetentionLimits()) + } + onLoaded(database.loadInitialSnapshot()) + _storeState.value = ConversationStoreState.Ready } catch (error: Exception) { Log.e(TAG, "Unable to restore private conversations", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to restore conversations" + ) } } } @@ -96,6 +115,20 @@ class ConversationRepository internal constructor( withContext(dispatcher) { Unit } } + internal suspend fun loadConversationAndWait( + conversationID: String + ): PersistedConversationSnapshot? = withContext(dispatcher) { + try { + database.loadConversation(conversationID) + } catch (error: Exception) { + Log.e(TAG, "Unable to load private conversation", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to load conversation" + ) + null + } + } + fun upsertMessage( conversationID: String, aliases: Set, @@ -104,20 +137,45 @@ class ConversationRepository internal constructor( 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}") - } + upsertMessageLocked(conversationID, aliases, displayName, message, isRead) } } + suspend fun upsertMessageAndWait( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ): Boolean = withContext(dispatcher) { + upsertMessageLocked(conversationID, aliases, displayName, message, isRead) + } + + private fun upsertMessageLocked( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ): Boolean = try { + val result = database.upsertMessage( + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + message = message, + isRead = isRead + ) + deleteStoredMedia(result.orphanedMediaPaths) + _storeState.value = ConversationStoreState.Ready + result.inserted + } catch (error: Exception) { + Log.e(TAG, "Unable to persist private message", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to save conversation" + ) + false + } + fun updateDeliveryStatus(messageID: String, status: DeliveryStatus) { scope.launch { try { @@ -138,6 +196,24 @@ class ConversationRepository internal constructor( } } + internal suspend fun setConversationReadAndWait( + conversationID: String, + isRead: Boolean + ): ConversationReadResult = withContext(dispatcher) { + try { + ConversationReadResult( + success = true, + affectedMessageID = database.setConversationRead(conversationID, isRead) + ) + } catch (error: Exception) { + Log.e(TAG, "Unable to update conversation read state", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to update conversation" + ) + ConversationReadResult(success = false) + } + } + fun mergeAliases(targetConversationID: String, aliases: Set) { scope.launch { try { @@ -150,18 +226,64 @@ class ConversationRepository internal constructor( 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}") - } + deleteConversationLocked(conversationID, aliases) } } + suspend fun deleteConversationAndWait( + conversationID: String, + aliases: Set + ): Boolean = withContext(dispatcher) { + deleteConversationLocked(conversationID, aliases) + } + + suspend fun restoreConversationAndWait( + conversationID: String, + aliases: Set, + displayName: String?, + messages: List, + readMessageIDs: Set + ): Boolean = withContext(dispatcher) { + try { + deleteStoredMedia( + database.restoreConversation( + conversationID, + aliases, + displayName, + messages, + readMessageIDs + ) + ) + _storeState.value = ConversationStoreState.Ready + true + } catch (error: Exception) { + Log.e(TAG, "Unable to restore deleted conversation", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to restore conversation" + ) + false + } + } + + private fun deleteConversationLocked( + conversationID: String, + aliases: Set + ): Boolean = try { + deleteStoredMedia(database.deleteConversation(conversationID, aliases)) + _storeState.value = ConversationStoreState.Ready + true + } catch (error: Exception) { + Log.e(TAG, "Unable to delete private conversation", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to delete conversation" + ) + false + } + fun deleteMessage(messageID: String) { scope.launch { try { - database.deleteMessage(messageID) + deleteStoredMedia(database.deleteMessage(messageID)) } catch (error: Exception) { Log.e(TAG, "Unable to delete private message: ${error.message}") } @@ -177,19 +299,54 @@ class ConversationRepository internal constructor( suspend fun clearAllAndWait(): Boolean = withContext(dispatcher) { try { database.clearAll() + _storeState.value = ConversationStoreState.Ready true } catch (error: Exception) { Log.e(TAG, "Unable to synchronously clear private conversations", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to erase conversations" + ) false } } + + private fun deleteStoredMedia(paths: Collection) { + if (paths.isEmpty()) return + com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths( + applicationContext, + paths + ) + } + + internal fun closeForTest() { + database.close() + } } +sealed interface ConversationStoreState { + data object Loading : ConversationStoreState + data object Ready : ConversationStoreState + data class Error(val message: String) : ConversationStoreState +} + +internal data class ConversationReadResult( + val success: Boolean, + val affectedMessageID: String? = null +) + +internal data class ConversationUpsertResult( + val inserted: Boolean, + val orphanedMediaPaths: Set +) + internal data class PersistedConversationSnapshot( val chats: Map>, val readMessageIDs: Set, val arrivalOrder: List, - val deletedMessageIDs: Set + val deletedMessageIDs: Set, + val unreadCounts: Map = emptyMap(), + val receivedAtByMessageID: Map = emptyMap(), + val arrivalSequenceByMessageID: Map = emptyMap() ) /** @@ -204,20 +361,26 @@ internal class ConversationDatabase( 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 + private val maxPayloadBytes: Long = MAX_PAYLOAD_BYTES, + private val maxMediaBytes: Long = MAX_MEDIA_BYTES, + private val storageCipher: ConversationStorageCipher = + AndroidConversationStorageCipher() ) : SQLiteOpenHelper(context, databaseName, null, DATABASE_VERSION) { companion object { + private const val TAG = "ConversationDatabase" const val MAX_MESSAGES_PER_CONVERSATION = 1_000 const val MAX_MESSAGES_TOTAL = 20_000 const val MAX_PAYLOAD_BYTES = 32L * 1024L * 1024L + const val MAX_MEDIA_BYTES = 256L * 1024L * 1024L internal const val DEFAULT_DATABASE_NAME = "private_conversations.db" - private const val DATABASE_VERSION = 1 + internal const val DATABASE_VERSION = 4 private const val PRUNE_INTERVAL = 64 private const val PRUNE_BATCH_SIZE = 256 } + private val applicationContext = context.applicationContext private var writesSinceGlobalPrune = 0 init { @@ -228,6 +391,24 @@ internal class ConversationDatabase( super.onConfigure(db) db.setForeignKeyConstraintsEnabled(true) db.execSQL("PRAGMA auto_vacuum = INCREMENTAL") + // Overwrite cells removed while migrating legacy plaintext payload columns. + db.rawQuery("PRAGMA secure_delete = ON", null).use { cursor -> + if (cursor.moveToFirst()) Unit + } + } + + override fun onOpen(db: SQLiteDatabase) { + super.onOpen(db) + // A v1 database may have written plaintext pages to its WAL before the encrypted migration. + // Truncating it after SQLite's upgrade transaction prevents those historical frames from + // lingering after the scrubbed rows are committed. + runCatching { + db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { cursor -> + if (cursor.moveToFirst()) Unit + } + }.onFailure { error -> + Log.w(TAG, "Unable to truncate conversation WAL: ${error.message}") + } } override fun onCreate(db: SQLiteDatabase) { @@ -236,6 +417,7 @@ internal class ConversationDatabase( CREATE TABLE conversations ( conversation_id TEXT COLLATE NOCASE PRIMARY KEY NOT NULL, display_name TEXT, + display_name_ciphertext BLOB, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) @@ -261,6 +443,7 @@ internal class ConversationDatabase( content TEXT NOT NULL, message_type INTEGER NOT NULL, sent_at INTEGER NOT NULL, + received_at INTEGER NOT NULL, is_relay INTEGER NOT NULL, original_sender TEXT, is_private INTEGER NOT NULL, @@ -276,6 +459,7 @@ internal class ConversationDatabase( delivery_reached INTEGER, delivery_total INTEGER, sender_nostr_pubkey TEXT, + payload_ciphertext BLOB NOT NULL, is_read INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE ON UPDATE CASCADE @@ -290,6 +474,7 @@ internal class ConversationDatabase( "CREATE INDEX idx_private_messages_read_arrival " + "ON private_messages(is_read, arrival_sequence)" ) + createAttachmentsTable(db) db.execSQL( "CREATE INDEX idx_conversation_aliases_conversation " + "ON conversation_aliases(conversation_id)" @@ -309,35 +494,197 @@ internal class ConversationDatabase( } 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" + var version = oldVersion + if (version == 1) { + migrateVersion1To2(db) + version = 2 + } + if (version == 2) { + db.execSQL( + "ALTER TABLE private_messages " + + "ADD COLUMN received_at INTEGER NOT NULL DEFAULT 0" + ) + db.execSQL( + "UPDATE private_messages SET received_at = sent_at WHERE received_at = 0" + ) + version = 3 + } + if (version == 3) { + createAttachmentsTable(db) + db.query( + "private_messages", + MESSAGE_COLUMNS, + null, + null, + null, + null, + null + ).use { cursor -> + while (cursor.moveToNext()) { + registerAttachmentLocked(db, cursor.toMessage()) + } + } + version = 4 + } + check(version == newVersion) { + "Missing conversation database migration from $version to $newVersion" } } - fun loadSnapshot(): PersistedConversationSnapshot { - val chats = linkedMapOf>() - val readIDs = linkedSetOf() - val arrivalOrder = mutableListOf() - val deletedMessageIDs = linkedSetOf() - readableDatabase.query( + private fun migrateVersion1To2(db: SQLiteDatabase) { + db.execSQL("ALTER TABLE conversations ADD COLUMN display_name_ciphertext BLOB") + db.execSQL("ALTER TABLE private_messages ADD COLUMN payload_ciphertext BLOB") + + db.query( + "conversations", + arrayOf("conversation_id", "display_name"), + "display_name IS NOT NULL AND display_name != ''", + null, + null, + null, + null + ).use { cursor -> + while (cursor.moveToNext()) { + val conversationID = cursor.getString(0) + val displayName = cursor.getString(1) + db.update( + "conversations", + ContentValues().apply { + put( + "display_name_ciphertext", + encryptText(displayName, conversationDisplayNameAad(conversationID)) + ) + putNull("display_name") + }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID) + ) + } + } + + db.query( "private_messages", - MESSAGE_COLUMNS, + LEGACY_MESSAGE_COLUMNS, null, null, null, null, "arrival_sequence ASC" + ).use { cursor -> + while (cursor.moveToNext()) { + val message = cursor.toLegacyMessage() + db.update( + "private_messages", + ContentValues().apply { + put( + "payload_ciphertext", + encryptMessagePayload(message) + ) + scrubLegacyPayloadColumns(this) + }, + "message_id = ?", + arrayOf(message.id) + ) + } + } + } + + private fun createAttachmentsTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS message_attachments ( + message_id TEXT PRIMARY KEY NOT NULL, + path_hash BLOB NOT NULL, + path_ciphertext BLOB NOT NULL, + byte_size INTEGER NOT NULL, + FOREIGN KEY(message_id) REFERENCES private_messages(message_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS idx_message_attachments_path_hash " + + "ON message_attachments(path_hash)" + ) + } + + fun loadSnapshot(): PersistedConversationSnapshot { + return loadMessages( + selection = null, + selectionArgs = null, + orderBy = "arrival_sequence ASC" + ) + } + + /** + * Loads one decrypted row per conversation for startup. Full histories are fetched only when + * the user opens a chat, keeping cold-start work proportional to conversation count rather + * than retained-message count. + */ + fun loadInitialSnapshot(): PersistedConversationSnapshot { + return loadMessages( + selection = """ + arrival_sequence IN ( + SELECT MAX(arrival_sequence) + FROM private_messages + GROUP BY conversation_id + ) + """.trimIndent(), + selectionArgs = null, + orderBy = "arrival_sequence ASC" + ) + } + + fun loadConversation(conversationID: String): PersistedConversationSnapshot { + val resolved = resolveStoredConversationLocked(readableDatabase, conversationID) + return loadMessages( + selection = "conversation_id = ? COLLATE NOCASE", + selectionArgs = arrayOf(resolved), + orderBy = "arrival_sequence ASC" + ) + } + + private fun loadMessages( + selection: String?, + selectionArgs: Array?, + orderBy: String + ): PersistedConversationSnapshot { + val chats = linkedMapOf>() + val readIDs = linkedSetOf() + val arrivalOrder = mutableListOf() + val receivedAtByMessageID = linkedMapOf() + val arrivalSequenceByMessageID = linkedMapOf() + readableDatabase.query( + "private_messages", + MESSAGE_COLUMNS, + selection, + selectionArgs, + null, + null, + orderBy ).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) + receivedAtByMessageID[message.id] = cursor.long("received_at") + arrivalSequenceByMessageID[message.id] = cursor.long("arrival_sequence") if (cursor.boolean("is_read")) readIDs.add(message.id) } } + return PersistedConversationSnapshot( + chats = chats.mapValues { it.value.toList() }, + readMessageIDs = readIDs, + arrivalOrder = arrivalOrder, + deletedMessageIDs = loadDeletedMessageIDs(), + unreadCounts = loadUnreadCounts(), + receivedAtByMessageID = receivedAtByMessageID, + arrivalSequenceByMessageID = arrivalSequenceByMessageID + ) + } + + private fun loadDeletedMessageIDs(): Set { readableDatabase.query( "deleted_private_messages", arrayOf("message_id"), @@ -347,26 +694,41 @@ internal class ConversationDatabase( null, "deleted_at ASC" ).use { cursor -> - while (cursor.moveToNext()) deletedMessageIDs.add(cursor.getString(0)) + return buildSet { + while (cursor.moveToNext()) add(cursor.getString(0)) + } } - return PersistedConversationSnapshot( - chats = chats.mapValues { it.value.toList() }, - readMessageIDs = readIDs, - arrivalOrder = arrivalOrder, - deletedMessageIDs = deletedMessageIDs - ) } + private fun loadUnreadCounts(): Map = + readableDatabase.rawQuery( + """ + SELECT conversation_id, COUNT(*) + FROM private_messages + WHERE is_read = 0 + GROUP BY conversation_id + """.trimIndent(), + null + ).use { cursor -> + buildMap { + while (cursor.moveToNext()) put(cursor.getString(0), cursor.getInt(1)) + } + } + fun upsertMessage( conversationID: String, aliases: Set, displayName: String?, message: BitchatMessage, isRead: Boolean - ) { + ): ConversationUpsertResult { val normalizedID = conversationID.trim() - if (normalizedID.isBlank()) return + if (normalizedID.isBlank()) { + return ConversationUpsertResult(inserted = false, orphanedMediaPaths = emptySet()) + } val now = System.currentTimeMillis() + val orphanedMediaPaths = linkedSetOf() + var messageInserted = false writableDatabase.inTransaction { if (isDeletedMessageLocked(this, message.id)) return@inTransaction mergeAliasesLocked( @@ -383,6 +745,10 @@ internal class ConversationDatabase( values, SQLiteDatabase.CONFLICT_IGNORE ) + messageInserted = inserted != -1L + if (inserted != -1L) { + registerAttachmentLocked(this, message) + } if (inserted == -1L) { val existingConversation = rawQuery( "SELECT conversation_id, is_read FROM private_messages WHERE message_id = ?", @@ -419,14 +785,18 @@ internal class ConversationDatabase( } } updateConversationMetadataLocked(this, normalizedID, displayName, now) - pruneConversationLocked(this, normalizedID) + orphanedMediaPaths += pruneConversationLocked(this, normalizedID) } writesSinceGlobalPrune += 1 if (writesSinceGlobalPrune >= PRUNE_INTERVAL) { writesSinceGlobalPrune = 0 - pruneToRetentionLimits() + orphanedMediaPaths += pruneToRetentionLimits() } + return ConversationUpsertResult( + inserted = messageInserted, + orphanedMediaPaths = orphanedMediaPaths + ) } fun updateDeliveryStatus(messageID: String, status: DeliveryStatus) { @@ -434,27 +804,48 @@ internal class ConversationDatabase( var found = false val existing = db.rawQuery( """ - SELECT delivery_type, delivery_text, delivery_at, delivery_reached, delivery_total + SELECT ${MESSAGE_COLUMNS.joinToString(",")} FROM private_messages WHERE message_id = ? """.trimIndent(), arrayOf(messageID) ).use { cursor -> if (cursor.moveToFirst()) { found = true - cursor.toDeliveryStatus() + cursor.toMessage().deliveryStatus } else { null } } if (!found) return - if (statusPriority(status) < statusPriority(existing)) return - db.update( - "private_messages", - deliveryValues(status), - "message_id = ?", - arrayOf(messageID) - ) + val mayReplace = when { + status is DeliveryStatus.Failed -> + existing !is DeliveryStatus.Delivered && existing !is DeliveryStatus.Read + existing is DeliveryStatus.Failed -> true + else -> statusPriority(status) >= statusPriority(existing) + } + if (!mayReplace) return + db.inTransaction { + val current = rawQuery( + "SELECT ${MESSAGE_COLUMNS.joinToString(",")} " + + "FROM private_messages WHERE message_id = ?", + arrayOf(messageID) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.toMessage() else null + } ?: return@inTransaction + update( + "private_messages", + ContentValues().apply { + putAll(deliveryValues(status, includeSensitiveText = false)) + put( + "payload_ciphertext", + encryptMessagePayload(current.copy(deliveryStatus = status)) + ) + }, + "message_id = ?", + arrayOf(messageID) + ) + } } fun markRead(messageID: String) { @@ -466,6 +857,43 @@ internal class ConversationDatabase( ) } + fun setConversationRead(conversationID: String, isRead: Boolean): String? { + val resolved = resolveStoredConversationLocked(writableDatabase, conversationID) + return writableDatabase.inTransaction { + if (isRead) { + update( + "private_messages", + ContentValues().apply { put("is_read", 1) }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(resolved) + ) + null + } else { + val latestMessageID = rawQuery( + """ + SELECT message_id + FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + ORDER BY arrival_sequence DESC + LIMIT 1 + """.trimIndent(), + arrayOf(resolved) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + if (latestMessageID != null) { + update( + "private_messages", + ContentValues().apply { put("is_read", 0) }, + "message_id = ?", + arrayOf(latestMessageID) + ) + } + latestMessageID + } + } + } + fun mergeAliases(targetConversationID: String, aliases: Set) { if (targetConversationID.isBlank()) return writableDatabase.inTransaction { @@ -479,7 +907,7 @@ internal class ConversationDatabase( } } - fun deleteConversation(conversationID: String, aliases: Set) { + fun deleteConversation(conversationID: String, aliases: Set): Set = writableDatabase.inTransaction { val ids = linkedSetOf() (aliases + conversationID).forEach { value -> @@ -496,6 +924,12 @@ internal class ConversationDatabase( arrayOf(System.currentTimeMillis(), id) ) } + val messageIDs = ids + .filter(String::isNotBlank) + .flatMapTo(linkedSetOf()) { id -> + queryMessageIDsLocked(this, id) + } + val attachmentCandidates = attachmentCandidatesLocked(this, messageIDs) ids.filter { it.isNotBlank() }.forEach { id -> delete( "conversations", @@ -504,11 +938,12 @@ internal class ConversationDatabase( ) } pruneDeletedMessageIDsLocked(this) + unreferencedAttachmentPathsLocked(this, attachmentCandidates) } - } - fun deleteMessage(messageID: String) { + fun deleteMessage(messageID: String): Set = writableDatabase.inTransaction { + val attachmentCandidates = attachmentCandidatesLocked(this, listOf(messageID)) insertWithOnConflict( "deleted_private_messages", null, @@ -530,39 +965,84 @@ internal class ConversationDatabase( null ) pruneDeletedMessageIDsLocked(this) + unreferencedAttachmentPathsLocked(this, attachmentCandidates) } + + fun restoreConversation( + conversationID: String, + aliases: Set, + displayName: String?, + messages: List, + readMessageIDs: Set + ): Set { + if (messages.isEmpty()) return emptySet() + val now = System.currentTimeMillis() + writableDatabase.inTransaction { + mergeAliasesLocked( + db = this, + targetConversationID = conversationID, + aliases = aliases + conversationID, + displayName = displayName, + now = now + ) + messages.forEach { message -> + delete( + "deleted_private_messages", + "message_id = ?", + arrayOf(message.id) + ) + val inserted = insertWithOnConflict( + "private_messages", + null, + message.toContentValues( + conversationID = conversationID, + isRead = message.id in readMessageIDs + ), + SQLiteDatabase.CONFLICT_IGNORE + ) + if (inserted != -1L) registerAttachmentLocked(this, message) + } + updateConversationMetadataLocked(this, conversationID, displayName, now) + } + return pruneToRetentionLimits() } fun clearAll() { + // Destroy the only usable copy of the history key before attempting filesystem cleanup. + storageCipher.destroyKey() writableDatabase.inTransaction { delete("conversation_aliases", null, null) + delete("message_attachments", 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 wal_checkpoint(TRUNCATE)", null).use { } writableDatabase.rawQuery("PRAGMA incremental_vacuum", null).use { } } - fun pruneToRetentionLimits() { + fun pruneToRetentionLimits(): Set { val db = writableDatabase + val orphanedMediaPaths = linkedSetOf() db.inTransaction { rawQuery( "SELECT conversation_id FROM conversations", null ).use { cursor -> while (cursor.moveToNext()) { - pruneConversationLocked(this, cursor.getString(0)) + orphanedMediaPaths += pruneConversationLocked(this, cursor.getString(0)) } } var stats = storageStatsLocked(this) while ( - stats.first > maxMessagesTotal || - stats.second > maxPayloadBytes + stats.messageCount > maxMessagesTotal || + stats.payloadBytes > maxPayloadBytes || + stats.mediaBytes > maxMediaBytes ) { - val candidateLimit = if (stats.first > maxMessagesTotal) { - (stats.first - maxMessagesTotal) + val candidateLimit = if (stats.messageCount > maxMessagesTotal) { + (stats.messageCount - maxMessagesTotal) .coerceAtMost(PRUNE_BATCH_SIZE.toLong()) .toInt() } else { @@ -601,15 +1081,19 @@ internal class ConversationDatabase( } if (candidates.isEmpty()) break tombstoneMessagesLocked(this, candidates) + val attachmentCandidates = attachmentCandidatesLocked(this, candidates) candidates.forEach { messageID -> delete("private_messages", "message_id = ?", arrayOf(messageID)) } + orphanedMediaPaths += + unreferencedAttachmentPathsLocked(this, attachmentCandidates) deleteEmptyConversationsLocked(this) pruneDeletedMessageIDsLocked(this) stats = storageStatsLocked(this) } } db.rawQuery("PRAGMA incremental_vacuum(128)", null).use { } + return orphanedMediaPaths } private fun mergeAliasesLocked( @@ -681,7 +1165,13 @@ internal class ConversationDatabase( null, ContentValues().apply { put("conversation_id", conversationID) - put("display_name", displayName) + putNull("display_name") + if (!displayName.isNullOrBlank()) { + put( + "display_name_ciphertext", + encryptText(displayName, conversationDisplayNameAad(conversationID)) + ) + } put("created_at", now) put("updated_at", now) }, @@ -698,7 +1188,13 @@ internal class ConversationDatabase( db.update( "conversations", ContentValues().apply { - if (!displayName.isNullOrBlank()) put("display_name", displayName) + if (!displayName.isNullOrBlank()) { + putNull("display_name") + put( + "display_name_ciphertext", + encryptText(displayName, conversationDisplayNameAad(conversationID)) + ) + } put("updated_at", now) }, "conversation_id = ? COLLATE NOCASE", @@ -743,14 +1239,17 @@ internal class ConversationDatabase( ) } - private fun pruneConversationLocked(db: SQLiteDatabase, conversationID: String) { + private fun pruneConversationLocked( + db: SQLiteDatabase, + conversationID: String + ): Set { 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( + if (excess == 0L) return emptySet() + return db.rawQuery( """ SELECT message_id FROM private_messages @@ -767,29 +1266,161 @@ internal class ConversationDatabase( ).use { cursor -> val ids = mutableListOf() while (cursor.moveToNext()) ids.add(cursor.getString(0)) + val attachmentCandidates = attachmentCandidatesLocked(db, ids) tombstoneMessagesLocked(db, ids) ids.forEach { db.delete("private_messages", "message_id = ?", arrayOf(it)) } pruneDeletedMessageIDsLocked(db) + unreferencedAttachmentPathsLocked(db, attachmentCandidates) } } - private fun storageStatsLocked(db: SQLiteDatabase): Pair = + private fun storageStatsLocked(db: SQLiteDatabase): ConversationStorageStats = db.rawQuery( """ SELECT COUNT(*), COALESCE(SUM( - length(content) + - COALESCE(length(encrypted_content), 0) + - COALESCE(length(mentions_json), 0) + COALESCE(length(payload_ciphertext), 0) + ), 0), + COALESCE(( + SELECT SUM(unique_attachment.byte_size) + FROM ( + SELECT MAX(byte_size) AS byte_size + FROM message_attachments + GROUP BY path_hash + ) AS unique_attachment ), 0) FROM private_messages """.trimIndent(), null ).use { cursor -> cursor.moveToFirst() - cursor.getLong(0) to cursor.getLong(1) + ConversationStorageStats( + messageCount = cursor.getLong(0), + payloadBytes = cursor.getLong(1), + mediaBytes = cursor.getLong(2) + ) } + private fun registerAttachmentLocked(db: SQLiteDatabase, message: BitchatMessage) { + if (message.type !in MEDIA_MESSAGE_TYPES) return + val path = appOwnedCanonicalPath(message.content) ?: return + val pathBytes = path.toByteArray(Charsets.UTF_8) + db.insertWithOnConflict( + "message_attachments", + null, + ContentValues().apply { + put("message_id", message.id) + put("path_hash", MessageDigest.getInstance("SHA-256").digest(pathBytes)) + put( + "path_ciphertext", + storageCipher.encrypt(pathBytes, attachmentPathAad(message.id)) + ) + put("byte_size", File(path).takeIf(File::isFile)?.length() ?: 0L) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + + private fun queryMessageIDsLocked( + db: SQLiteDatabase, + conversationID: String + ): List = + db.query( + "private_messages", + arrayOf("message_id"), + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID), + null, + null, + null + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + + private fun attachmentCandidatesLocked( + db: SQLiteDatabase, + messageIDs: Collection + ): List { + if (messageIDs.isEmpty()) return emptyList() + return buildList { + messageIDs.toList().chunked(400).forEach { chunk -> + val placeholders = chunk.joinToString(",") { "?" } + db.rawQuery( + """ + SELECT message_id, path_hash, path_ciphertext + FROM message_attachments + WHERE message_id IN ($placeholders) + """.trimIndent(), + chunk.toTypedArray() + ).use { cursor -> + while (cursor.moveToNext()) { + val messageID = cursor.getString(0) + val path = storageCipher.decrypt( + cursor.getBlob(2), + attachmentPathAad(messageID) + ).toString(Charsets.UTF_8) + add( + AttachmentCandidate( + pathHash = cursor.getBlob(1), + canonicalPath = path + ) + ) + } + } + } + } + } + + private fun unreferencedAttachmentPathsLocked( + db: SQLiteDatabase, + candidates: Collection + ): Set = candidates + .filter { candidate -> + db.rawQuery( + "SELECT COUNT(*) FROM message_attachments WHERE hex(path_hash) = ?", + arrayOf(candidate.pathHash.toHex().uppercase()) + ).use { cursor -> + cursor.moveToFirst() + cursor.getLong(0) == 0L + } + } + .mapTo(linkedSetOf(), AttachmentCandidate::canonicalPath) + + private fun appOwnedCanonicalPath(value: String): String? { + val file = runCatching { File(value.trim()).canonicalFile }.getOrNull() ?: return null + val roots = listOf(applicationContext.filesDir, applicationContext.cacheDir) + .mapNotNull { runCatching { it.canonicalFile }.getOrNull() } + return file.path.takeIf { path -> + roots.any { root -> + path == root.path || path.startsWith(root.path + File.separator) + } + } + } + + private fun attachmentPathAad(messageID: String): ByteArray = + "attachment:$messageID:path".toByteArray(Charsets.UTF_8) + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private data class ConversationStorageStats( + val messageCount: Long, + val payloadBytes: Long, + val mediaBytes: Long + ) + + private data class AttachmentCandidate( + val pathHash: ByteArray, + val canonicalPath: String + ) + + private val MEDIA_MESSAGE_TYPES = setOf( + BitchatMessageType.Audio, + BitchatMessageType.Image, + BitchatMessageType.File + ) + private fun pruneCandidatesLocked( db: SQLiteDatabase, readOnly: Boolean, @@ -898,42 +1529,49 @@ internal class ConversationDatabase( ): ContentValues = ContentValues().apply { put("message_id", id) put("conversation_id", conversationID) - put("sender", sender) - put("content", content) + // Version 1 columns remain for lossless migration compatibility, but sensitive values are + // scrubbed and only the authenticated encrypted payload is populated for new writes. + put("sender", "") + put("content", "") put("message_type", type.ordinal) put("sent_at", timestamp.time) + put("received_at", System.currentTimeMillis()) put("is_relay", isRelay.asInt()) - put("original_sender", originalSender) + putNull("original_sender") 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) + putNull("recipient_nickname") + putNull("sender_peer_id") + putNull("mentions_json") + putNull("channel_name") + putNull("encrypted_content") put("is_encrypted", isEncrypted.asInt()) - putAll(deliveryValues(deliveryStatus)) - put("sender_nostr_pubkey", senderNostrPubkey) + putAll(deliveryValues(deliveryStatus, includeSensitiveText = false)) + putNull("sender_nostr_pubkey") + put("payload_ciphertext", encryptMessagePayload(this@toContentValues)) put("is_read", isRead.asInt()) } - private fun deliveryValues(status: DeliveryStatus?): ContentValues = ContentValues().apply { + private fun deliveryValues( + status: DeliveryStatus?, + includeSensitiveText: Boolean = true + ): 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) + if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text") put("delivery_at", status.at.time) } is DeliveryStatus.Read -> { put("delivery_type", 4) - put("delivery_text", status.by) + if (includeSensitiveText) put("delivery_text", status.by) else putNull("delivery_text") put("delivery_at", status.at.time) } is DeliveryStatus.Failed -> { put("delivery_type", 5) - put("delivery_text", status.reason) + if (includeSensitiveText) put("delivery_text", status.reason) else putNull("delivery_text") } is DeliveryStatus.PartiallyDelivered -> { put("delivery_type", 6) @@ -953,7 +1591,34 @@ internal class ConversationDatabase( is DeliveryStatus.Read -> 5 } - private fun Cursor.toMessage(): BitchatMessage = BitchatMessage( + private fun Cursor.toMessage(): BitchatMessage { + val messageID = string("message_id") + val encryptedPayload = blobOrNull("payload_ciphertext") + ?: error("Conversation message $messageID has no encrypted payload") + val payload = decryptMessagePayload(messageID, encryptedPayload) + return BitchatMessage( + id = messageID, + sender = payload.sender, + content = payload.content, + type = BitchatMessageType.entries.getOrElse(int("message_type")) { + BitchatMessageType.Message + }, + timestamp = Date(long("sent_at")), + isRelay = boolean("is_relay"), + originalSender = payload.originalSender, + isPrivate = boolean("is_private"), + recipientNickname = payload.recipientNickname, + senderPeerID = payload.senderPeerID, + mentions = payload.mentions, + channel = payload.channel, + encryptedContent = payload.encryptedContent, + isEncrypted = boolean("is_encrypted"), + deliveryStatus = toDeliveryStatus(payload.deliveryText), + senderNostrPubkey = payload.senderNostrPubkey + ) + } + + private fun Cursor.toLegacyMessage(): BitchatMessage = BitchatMessage( id = string("message_id"), sender = string("sender"), content = string("content"), @@ -974,18 +1639,19 @@ internal class ConversationDatabase( senderNostrPubkey = nullableString("sender_nostr_pubkey") ) - private fun Cursor.toDeliveryStatus(): DeliveryStatus? = when (int("delivery_type")) { + private fun Cursor.toDeliveryStatus(deliveryText: String? = nullableString("delivery_text")): + DeliveryStatus? = when (int("delivery_type")) { 1 -> DeliveryStatus.Sending 2 -> DeliveryStatus.Sent 3 -> DeliveryStatus.Delivered( - to = nullableString("delivery_text").orEmpty(), + to = deliveryText.orEmpty(), at = Date(nullableLong("delivery_at") ?: 0L) ) 4 -> DeliveryStatus.Read( - by = nullableString("delivery_text").orEmpty(), + by = deliveryText.orEmpty(), at = Date(nullableLong("delivery_at") ?: 0L) ) - 5 -> DeliveryStatus.Failed(nullableString("delivery_text").orEmpty()) + 5 -> DeliveryStatus.Failed(deliveryText.orEmpty()) 6 -> DeliveryStatus.PartiallyDelivered( reached = nullableInt("delivery_reached") ?: 0, total = nullableInt("delivery_total") ?: 0 @@ -1015,6 +1681,111 @@ internal class ConversationDatabase( index(column).let { if (isNull(it)) null else getBlob(it) } private fun Boolean.asInt(): Int = if (this) 1 else 0 + private fun encryptMessagePayload(message: BitchatMessage): ByteArray { + val json = JSONObject().apply { + put("sender", message.sender) + put("content", message.content) + putNullable("original_sender", message.originalSender) + putNullable("recipient_nickname", message.recipientNickname) + putNullable("sender_peer_id", message.senderPeerID) + putNullable("mentions_json", message.mentions?.let(::JSONArray)) + putNullable("channel_name", message.channel) + putNullable( + "encrypted_content", + message.encryptedContent?.let { + Base64.encodeToString(it, Base64.NO_WRAP) + } + ) + putNullable("delivery_text", message.deliveryStatus.sensitiveText()) + putNullable("sender_nostr_pubkey", message.senderNostrPubkey) + } + return storageCipher.encrypt( + json.toString().toByteArray(Charsets.UTF_8), + messagePayloadAad(message.id) + ) + } + + private fun decryptMessagePayload( + messageID: String, + encryptedPayload: ByteArray + ): StoredMessagePayload { + val json = JSONObject( + storageCipher.decrypt(encryptedPayload, messagePayloadAad(messageID)) + .toString(Charsets.UTF_8) + ) + return StoredMessagePayload( + sender = json.getString("sender"), + content = json.getString("content"), + originalSender = json.optionalString("original_sender"), + recipientNickname = json.optionalString("recipient_nickname"), + senderPeerID = json.optionalString("sender_peer_id"), + mentions = json.optionalArray("mentions_json")?.let(::jsonStringList), + channel = json.optionalString("channel_name"), + encryptedContent = json.optionalString("encrypted_content")?.let { + Base64.decode(it, Base64.NO_WRAP) + }, + deliveryText = json.optionalString("delivery_text"), + senderNostrPubkey = json.optionalString("sender_nostr_pubkey") + ) + } + + private fun encryptText(value: String, associatedData: ByteArray): ByteArray = + storageCipher.encrypt(value.toByteArray(Charsets.UTF_8), associatedData) + + private fun messagePayloadAad(messageID: String): ByteArray = + "message:$messageID".toByteArray(Charsets.UTF_8) + + private fun conversationDisplayNameAad(conversationID: String): ByteArray = + "conversation:$conversationID:display-name".toByteArray(Charsets.UTF_8) + + private fun scrubLegacyPayloadColumns(values: ContentValues) { + values.put("sender", "") + values.put("content", "") + values.putNull("original_sender") + values.putNull("recipient_nickname") + values.putNull("sender_peer_id") + values.putNull("mentions_json") + values.putNull("channel_name") + values.putNull("encrypted_content") + values.putNull("delivery_text") + values.putNull("sender_nostr_pubkey") + } + + private fun JSONObject.putNullable(key: String, value: Any?) { + put(key, value ?: JSONObject.NULL) + } + + private fun JSONObject.optionalString(key: String): String? = + if (!has(key) || isNull(key)) null else getString(key) + + private fun JSONObject.optionalArray(key: String): JSONArray? = + if (!has(key) || isNull(key)) null else getJSONArray(key) + + private fun jsonStringList(array: JSONArray): List = + buildList(array.length()) { + for (index in 0 until array.length()) add(array.getString(index)) + } + + private fun DeliveryStatus?.sensitiveText(): String? = when (this) { + is DeliveryStatus.Delivered -> to + is DeliveryStatus.Read -> by + is DeliveryStatus.Failed -> reason + else -> null + } + + private data class StoredMessagePayload( + val sender: String, + val content: String, + val originalSender: String?, + val recipientNickname: String?, + val senderPeerID: String?, + val mentions: List?, + val channel: String?, + val encryptedContent: ByteArray?, + val deliveryText: String?, + val senderNostrPubkey: String? + ) + private val MESSAGE_COLUMNS = arrayOf( "arrival_sequence", "message_id", @@ -1023,6 +1794,7 @@ internal class ConversationDatabase( "content", "message_type", "sent_at", + "received_at", "is_relay", "original_sender", "is_private", @@ -1038,6 +1810,11 @@ internal class ConversationDatabase( "delivery_reached", "delivery_total", "sender_nostr_pubkey", + "payload_ciphertext", "is_read" ) + + private val LEGACY_MESSAGE_COLUMNS = MESSAGE_COLUMNS.filterNot { + it == "payload_ciphertext" || it == "received_at" + }.toTypedArray() } diff --git a/app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt b/app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt new file mode 100644 index 00000000..12acb417 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt @@ -0,0 +1,98 @@ +package com.bitchat.android.services + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Encrypts private-conversation payloads with a key that never leaves Android Keystore. + * + * Every value is bound to its database identity through AES-GCM associated data. This prevents an + * encrypted payload copied from one message or conversation row from being accepted in another. + * Deleting the dedicated alias provides practical cryptographic erasure before SQLite pages, WAL + * records, and filesystem blocks are reclaimed. + */ +internal interface ConversationStorageCipher { + fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray + fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray + fun destroyKey() +} +internal class AndroidConversationStorageCipher( + private val keyAlias: String = DEFAULT_KEY_ALIAS +) : ConversationStorageCipher { + companion object { + internal const val DEFAULT_KEY_ALIAS = "bitchat_conversation_storage_v1" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val ENVELOPE_VERSION: Byte = 1 + private const val GCM_TAG_BITS = 128 + private const val IV_BYTES = 12 + } + + private val keyLock = Any() + @Volatile + private var cachedKey: SecretKey? = null + + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + cipher.updateAAD(associatedData) + val ciphertext = cipher.doFinal(plaintext) + check(cipher.iv.size == IV_BYTES) { "Unexpected AES-GCM IV length" } + return byteArrayOf(ENVELOPE_VERSION) + cipher.iv + ciphertext + } + + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray { + require(envelope.size > 1 + IV_BYTES) { "Conversation payload envelope is truncated" } + require(envelope[0] == ENVELOPE_VERSION) { + "Unsupported conversation payload envelope version" + } + val iv = envelope.copyOfRange(1, 1 + IV_BYTES) + val ciphertext = envelope.copyOfRange(1 + IV_BYTES, envelope.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + cipher.updateAAD(associatedData) + return cipher.doFinal(ciphertext) + } + + override fun destroyKey() { + synchronized(keyLock) { + cachedKey = null + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + if (keyStore.containsAlias(keyAlias)) { + keyStore.deleteEntry(keyAlias) + } + } + } + + private fun getOrCreateKey(): SecretKey = + cachedKey ?: synchronized(keyLock) { + cachedKey ?: run { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + (keyStore.getKey(keyAlias, null) as? SecretKey) ?: generateKey() + }.also { cachedKey = it } + } + + private fun generateKey(): SecretKey { + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + KEYSTORE_PROVIDER + ) + generator.init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build() + ) + return generator.generateKey() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt index addc02c0..dc76a57d 100644 --- a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt +++ b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt @@ -1,6 +1,7 @@ package com.bitchat.android.services import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.runBlocking /** * Reflects an incoming transport message into process-wide state before any downstream effects. @@ -15,7 +16,12 @@ internal object IncomingMessageAdmission { message.isPrivate -> { val peerID = message.senderPeerID?.takeIf(String::isNotBlank) ?: return false - AppStateStore.addPrivateMessage(peerID, message) + // Mesh transport callbacks run on their background service workers. Wait for the + // serialized SQLite transaction so a notification can never advertise a message + // that an immediate process death would lose. + runBlocking { + AppStateStore.addPrivateMessageDurably(peerID, message) + } } message.channel != null -> { 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 f5cc92ed..f42a8dff 100644 --- a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt +++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt @@ -11,25 +11,45 @@ import com.bitchat.android.model.BitchatMessage */ internal object PrivateMessageArrivalOrder { private val sequenceByMessageID = mutableMapOf() + private val receivedAtByMessageID = mutableMapOf() private var nextSequence = 0L - fun record(messageID: String) { + fun record(messageID: String, receivedAt: Long = System.currentTimeMillis()) { synchronized(this) { if (messageID !in sequenceByMessageID) { sequenceByMessageID[messageID] = nextSequence++ + receivedAtByMessageID[messageID] = receivedAt } } } - fun restore(persistedOrder: List, liveMessageIDs: List) { + fun restore( + persistedOrder: List, + liveMessageIDs: List, + persistedReceivedAt: Map = emptyMap(), + persistedSequences: Map = emptyMap() + ) { synchronized(this) { + val previousSequences = sequenceByMessageID.toMap() + val previousReceivedAt = receivedAtByMessageID.toMap() sequenceByMessageID.clear() - nextSequence = 0L + receivedAtByMessageID.clear() + nextSequence = (persistedSequences.values.maxOrNull() ?: -1L) + 1L (persistedOrder + liveMessageIDs).forEach { messageID -> if (messageID !in sequenceByMessageID) { - sequenceByMessageID[messageID] = nextSequence++ + val sequence = persistedSequences[messageID] + ?: previousSequences[messageID] + ?: nextSequence++ + sequenceByMessageID[messageID] = sequence + (persistedReceivedAt[messageID] ?: previousReceivedAt[messageID])?.let { + receivedAtByMessageID[messageID] = it + } } } + nextSequence = maxOf( + nextSequence, + (sequenceByMessageID.values.maxOrNull() ?: -1L) + 1L + ) } } @@ -37,6 +57,10 @@ internal object PrivateMessageArrivalOrder { sequenceByMessageID[messageID] } + fun receivedAtOf(messageID: String): Long? = synchronized(this) { + receivedAtByMessageID[messageID] + } + fun order(messages: List): List { synchronized(this) { if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) { @@ -49,6 +73,7 @@ internal object PrivateMessageArrivalOrder { fun clear() { synchronized(this) { sequenceByMessageID.clear() + receivedAtByMessageID.clear() nextSequence = 0L } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index e9c8e343..da3738b8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -98,6 +98,12 @@ fun ChatScreen(viewModel: ChatViewModel) { var forceScrollToBottom by remember { mutableStateOf(false) } var isScrolledUp by remember { mutableStateOf(false) } + LaunchedEffect(selectedPrivatePeer) { + selectedPrivatePeer?.let { peerID -> + messageText = TextFieldValue(viewModel.conversationDraft(peerID)) + } + } + // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { showPasswordDialog = showPasswordPrompt @@ -356,14 +362,19 @@ fun ChatScreen(viewModel: ChatViewModel) { messageText = messageText, onMessageTextChange = { newText: TextFieldValue -> messageText = newText + viewModel.setConversationDraft(selectedPrivatePeer, newText.text) viewModel.updateCommandSuggestions(newText.text) viewModel.updateMentionSuggestions(newText.text) }, onSend = { if (messageText.text.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.text.trim()) - messageText = TextFieldValue("") - forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll + viewModel.sendMessage(messageText.text.trim()) { accepted -> + if (accepted) { + messageText = TextFieldValue("") + viewModel.setConversationDraft(selectedPrivatePeer, "") + forceScrollToBottom = !forceScrollToBottom + } + } } }, onSendVoiceNote = { peer, onionOrChannel, path -> 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 b72ce65c..205b17d5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.MeshService @@ -58,6 +59,7 @@ class ChatViewModel( companion object { private const val TAG = "ChatViewModel" + private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L } fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { @@ -109,6 +111,8 @@ class ChatViewModel( private val seenMessageStore by lazy { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) } + private val conversationListPreferences = + com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication()) private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) @@ -131,7 +135,13 @@ class ChatViewModel( seenMessageStore.markReadLocally(messageID) } ) - private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) + private val commandProcessor = CommandProcessor( + state, + messageManager, + channelManager, + privateChatManager, + viewModelScope + ) private val notificationManager = NotificationManager( application.applicationContext, NotificationManagerCompat.from(application.applicationContext), @@ -193,12 +203,17 @@ class ChatViewModel( val privateChats: StateFlow>> = state.privateChats val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages - internal val conversations: StateFlow> = combine( + internal val conversationStoreState = + com.bitchat.android.services.AppStateStore.conversationStoreState + private val conversationPresencePeers = MutableStateFlow>(emptyList()) + private val conversationPresenceRemovalJobs = mutableMapOf() + private val baseConversations = combine( state.unreadPrivateMessages, state.privateChats, state.nickname, - state.connectedPeers - ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs -> + conversationPresencePeers, + com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts + ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs, unreadCounts -> val seenStore = seenMessageStore val connectedIdentitiesByPeer = connectedPeerIDs.associateWith { peerID -> runCatching { @@ -215,7 +230,8 @@ class ChatViewModel( isMessageRead = { message -> com.bitchat.android.services.AppStateStore.isPrivateMessageRead(message.id) || seenStore.hasBeenReadLocally(message.id) - } + }, + persistedUnreadCounts = unreadCounts ).map { summary -> val resolution = ContactDirectory.resolve(summary.conversationID) val resolvedNostrPubkey = summary.nostrPubkey @@ -251,7 +267,25 @@ class ChatViewModel( .mapNotNull(GeohashConversationRegistry::get) .firstOrNull() ) - }.let(::sortConversationSummaries) + } + } + + internal val conversations: StateFlow> = combine( + baseConversations, + conversationListPreferences.pinned, + conversationListPreferences.muted, + conversationListPreferences.drafts + ) { summaries, pinned, muted, drafts -> + sortConversationSummaries( + summaries.map { summary -> + val key = summary.conversationID.lowercase() + summary.copy( + isPinned = key in pinned, + isMuted = key in muted, + draft = drafts[key] + ) + } + ) } .flowOn(Dispatchers.IO) .stateIn( @@ -305,6 +339,7 @@ class ChatViewModel( } init { + observeConversationPresenceWithDisconnectGrace() // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } @@ -338,7 +373,12 @@ class ChatViewModel( } } catch (_: Exception) { } } viewModelScope.launch { - try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> + try { + combine( + com.bitchat.android.services.AppStateStore.privateMessages, + com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts + ) { byPeer, unreadCounts -> byPeer to unreadCounts } + .collect { (byPeer, unreadCounts) -> val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) { val canonical = ContactDirectory.canonicalizePrivateChats(byPeer) val unread = try { @@ -353,7 +393,9 @@ class ChatViewModel( !seenMessageStore.hasBeenReadLocally(message.id) } } - .keys + .keys + unreadCounts + .filterValues { it > 0 } + .keys } catch (_: Exception) { state.getUnreadPrivateMessagesValue() } @@ -380,6 +422,42 @@ class ChatViewModel( // Removed background location notes subscription. Notes now load only when sheet opens. } + /** + * Mesh discovery can briefly drop a peer while transports hand over. Preserve its online + * treatment for a short grace window to keep conversation rows from jumping between sections. + * New connections still appear immediately. + */ + private fun observeConversationPresenceWithDisconnectGrace() { + viewModelScope.launch { + state.connectedPeers.collect { connected -> + val current = connected.toSet() + current.forEach { peerID -> + conversationPresenceRemovalJobs.remove(peerID)?.cancel() + } + + val displayed = conversationPresencePeers.value.toMutableList() + connected.forEach { peerID -> + if (peerID !in displayed) displayed.add(peerID) + } + if (displayed != conversationPresencePeers.value) { + conversationPresencePeers.value = displayed + } + + (displayed.toSet() - current).forEach { peerID -> + if (peerID in conversationPresenceRemovalJobs) return@forEach + conversationPresenceRemovalJobs[peerID] = launch { + delay(CONVERSATION_DISCONNECT_GRACE_MS) + if (peerID !in state.connectedPeers.value) { + conversationPresencePeers.value = + conversationPresencePeers.value - peerID + } + conversationPresenceRemovalJobs.remove(peerID) + } + } + } + } + } + fun cancelMediaSend(messageId: String) { // Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state mediaSendingManager.cancelMediaSend(messageId) @@ -511,6 +589,13 @@ class ChatViewModel( val (conversationID, success) = withContext(Dispatchers.IO) { val canonicalID = ContactDirectory.canonicalConversationId(peerID) + com.bitchat.android.services.AppStateStore + .loadPrivateConversationHistory(canonicalID) + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) val unreadAliases = matchingUnreadAliases( unreadConversationIDs = state.getUnreadPrivateMessagesValue(), canonicalConversationID = canonicalID, @@ -531,7 +616,17 @@ class ChatViewModel( } fun endPrivateChat() { + val conversationID = state.getSelectedPrivateChatPeerValue() privateChatManager.endPrivateChat() + if (conversationID != null) { + com.bitchat.android.services.AppStateStore + .releasePrivateConversationHistory(conversationID) + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) + } // Notify notification manager that no private chat is active setCurrentPrivateChatPeer(null) // Clear mesh mention notifications since user is now back in mesh chat @@ -540,31 +635,27 @@ class ChatViewModel( hidePrivateChatSheet() } - fun deletePrivateConversation(peerOrConversationID: String) { + internal suspend fun deletePrivateConversation( + peerOrConversationID: String + ): com.bitchat.android.services.DeletedPrivateConversation? { val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) - val deletedMessages = state.getPrivateChatsValue() - .filterKeys { key -> - ContactDirectory.canonicalConversationId(key) - .equals(canonicalID, ignoreCase = true) - } - .values - .flatten() + val wasPinned = conversationListPreferences.isPinned(canonicalID) + val wasMuted = conversationListPreferences.isMuted(canonicalID) + val draft = conversationListPreferences.draftFor(canonicalID) 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 - ) - } + val deletion = withContext(Dispatchers.IO) { + com.bitchat.android.services.AppStateStore + .deletePrivateConversationAndWait(canonicalID) + }?.copy( + wasPinned = wasPinned, + wasMuted = wasMuted, + draft = draft + ) ?: return null + conversationListPreferences.removeConversation(canonicalID) state.setPrivateChats( ContactDirectory.canonicalizePrivateChats( @@ -574,7 +665,7 @@ class ChatViewModel( state.setUnreadPrivateMessages( state.getUnreadPrivateMessagesValue() - unreadAliases ) - seenMessageStore.remove(deletedMessageIDs) + seenMessageStore.remove(deletion.messageIDs) val selected = state.getSelectedPrivateChatPeerValue() if ( @@ -594,6 +685,81 @@ class ChatViewModel( hidePrivateChatSheet() } clearNotificationsForSender(canonicalID) + notificationManager.removeConversationShortcut(canonicalID) + return deletion + } + + internal suspend fun restoreDeletedConversation( + deletion: com.bitchat.android.services.DeletedPrivateConversation + ): Boolean { + val restored = withContext(Dispatchers.IO) { + com.bitchat.android.services.AppStateStore + .restoreDeletedConversation(deletion) + } + if (!restored) return false + if (deletion.wasPinned != conversationListPreferences.isPinned(deletion.conversationID)) { + conversationListPreferences.togglePinned(deletion.conversationID) + } + if (deletion.wasMuted != conversationListPreferences.isMuted(deletion.conversationID)) { + conversationListPreferences.toggleMuted(deletion.conversationID) + } + deletion.draft?.let { + conversationListPreferences.setDraft(deletion.conversationID, it) + } + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) + if (deletion.unreadMessageCount > 0) { + state.setUnreadPrivateMessages( + state.getUnreadPrivateMessagesValue() + deletion.conversationID + ) + } + return true + } + + internal suspend fun setConversationRead( + conversationID: String, + isRead: Boolean + ): Boolean { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val updated = withContext(Dispatchers.IO) { + com.bitchat.android.services.AppStateStore + .setPrivateConversationRead(canonicalID, isRead) + } + if (!updated) return false + state.setUnreadPrivateMessages( + if (isRead) { + state.getUnreadPrivateMessagesValue().filterNotTo(mutableSetOf()) { + ContactDirectory.canonicalConversationId(it) + .equals(canonicalID, ignoreCase = true) + } + } else { + state.getUnreadPrivateMessagesValue() + canonicalID + } + ) + return true + } + + internal fun toggleConversationPinned(conversationID: String) { + conversationListPreferences.togglePinned(conversationID) + } + + internal fun toggleConversationMuted(conversationID: String) { + conversationListPreferences.toggleMuted(conversationID) + } + + internal fun conversationDraft(conversationID: String?): String = + conversationID + ?.let(ContactDirectory::canonicalConversationId) + ?.lowercase() + ?.let(conversationListPreferences.drafts.value::get) + .orEmpty() + + internal fun setConversationDraft(conversationID: String?, text: String) { + if (conversationID.isNullOrBlank()) return + conversationListPreferences.setDraft(conversationID, text) } // MARK: - Open Latest Unread Private Chat @@ -652,8 +818,14 @@ class ChatViewModel( // MARK: - Message Sending - fun sendMessage(content: String) { - if (content.isEmpty()) return + fun sendMessage( + content: String, + onAccepted: (Boolean) -> Unit = {} + ) { + if (content.isEmpty()) { + onAccepted(false) + return + } // Check for commands if (content.startsWith("/")) { @@ -671,6 +843,7 @@ class ChatViewModel( mesh.sendMessage(messageContent, mentions, channel) } }, this) + onAccepted(true) return } @@ -699,18 +872,33 @@ class ChatViewModel( } // Send private message val recipientNickname = nicknameForPeer(selectedPeer) - privateChatManager.sendPrivateMessage( - content, - selectedPeer, - recipientNickname, - state.getNicknameValue(), - mesh.myPeerID - ) { messageContent, peerID, recipientNicknameParam, messageId -> - val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh) - val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) - if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) { - messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent) + val destination = selectedPeer + viewModelScope.launch { + val accepted = privateChatManager.sendPrivateMessageDurably( + content, + destination, + recipientNickname, + state.getNicknameValue(), + mesh.myPeerID + ) { messageContent, peerID, recipientNicknameParam, messageId -> + val router = com.bitchat.android.services.MessageRouter.getInstance( + getApplication(), + mesh + ) + val route = router.sendPrivate( + messageContent, + peerID, + recipientNicknameParam, + messageId + ) + if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) { + messageManager.updateMessageDeliveryStatus( + messageId, + com.bitchat.android.model.DeliveryStatus.Sent + ) + } } + onAccepted(accepted) } } else { // Check if we're in a location channel @@ -756,6 +944,7 @@ class ChatViewModel( mesh.sendMessage(content, mentions, null) } } + onAccepted(true) } } @@ -1159,6 +1348,7 @@ class ChatViewModel( channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() dataManager.clearAllData() + conversationListPreferences.clearAll() // Clear seen message store try { @@ -1169,7 +1359,7 @@ class ChatViewModel( clearAllCryptographicData() // Clear all notifications - notificationManager.clearAllNotifications() + notificationManager.clearAllNotifications(removeConversationShortcuts = true) // Clear all media files com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) 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 5a42f82b..f306ca04 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -4,6 +4,8 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Handles processing of IRC-style commands @@ -12,7 +14,8 @@ class CommandProcessor( private val state: ChatState, private val messageManager: MessageManager, private val channelManager: ChannelManager, - private val privateChatManager: PrivateChatManager + private val privateChatManager: PrivateChatManager, + private val coroutineScope: CoroutineScope? = null ) { // Available commands list @@ -90,15 +93,15 @@ class CommandProcessor( if (parts.size > 2) { val messageContent = parts.drop(2).joinToString(" ") val recipientNickname = getPeerNickname(peerID, meshService) - privateChatManager.sendPrivateMessage( + sendPrivateMessage( messageContent, peerID, recipientNickname, state.getNicknameValue(), - getMyPeerID(meshService) - ) { content, peerIdParam, recipientNicknameParam, messageId -> - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel) - } + getMyPeerID(meshService), + meshService, + viewModel + ) } else { val systemMessage = BitchatMessage( sender = "system", @@ -303,15 +306,15 @@ class CommandProcessor( // Send as regular message if (state.getSelectedPrivateChatPeerValue() != null) { val peerID = state.getSelectedPrivateChatPeerValue()!! - privateChatManager.sendPrivateMessage( + sendPrivateMessage( actionMessage, peerID, getPeerNickname(peerID, meshService), state.getNicknameValue(), - myPeerID - ) { content, peerIdParam, recipientNicknameParam, messageId -> - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel) - } + myPeerID, + meshService, + viewModel + ) } else if (isInLocationChannel) { // Let the transport layer add the echo; just send it out onSendMessage(actionMessage, emptyList(), null) @@ -527,7 +530,51 @@ class CommandProcessor( private fun getMyPeerID(meshService: MeshService): String { return meshService.myPeerID } - + + private fun sendPrivateMessage( + content: String, + peerID: String, + recipientNickname: String?, + senderNickname: String?, + myPeerID: String, + meshService: MeshService, + viewModel: ChatViewModel? + ) { + val send: (String, String, String, String) -> Unit = + { messageContent, peerIdParam, recipientNicknameParam, messageId -> + sendPrivateMessageVia( + meshService, + messageContent, + peerIdParam, + recipientNicknameParam, + messageId, + viewModel + ) + } + val scope = coroutineScope + if (scope == null) { + privateChatManager.sendPrivateMessage( + content, + peerID, + recipientNickname, + senderNickname, + myPeerID, + send + ) + } else { + scope.launch { + privateChatManager.sendPrivateMessageDurably( + content, + peerID, + recipientNickname, + senderNickname, + myPeerID, + send + ) + } + } + } + private fun sendPrivateMessageVia( meshService: MeshService, content: String, diff --git a/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt index 6d327413..5ec4b02f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt +++ b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.services.PrivateMessageArrivalOrder /** @@ -15,12 +16,17 @@ internal data class ConversationSummary( val latestActivityOrder: Long, val latestMessageType: BitchatMessageType, val latestMessagePreview: String, + val latestMessageIsOutgoing: Boolean = false, + val latestDeliveryStatus: DeliveryStatus? = null, val transport: DirectMessageTransport, val nostrPubkey: String?, val identityAliases: Set, val isConnected: Boolean = false, val connectedPeerID: String? = null, - val sourceGeohash: String? = null + val sourceGeohash: String? = null, + val isPinned: Boolean = false, + val isMuted: Boolean = false, + val draft: String? = null ) internal fun buildConversationSummaries( @@ -28,27 +34,38 @@ internal fun buildConversationSummaries( privateChats: Map>, currentUserIdentifiers: Set, canonicalize: (String) -> String, - isMessageRead: (BitchatMessage) -> Boolean + isMessageRead: (BitchatMessage) -> Boolean, + persistedUnreadCounts: Map = emptyMap() ): List { if (privateChats.isEmpty()) return emptyList() - val currentUsers = currentUserIdentifiers.filterTo(mutableSetOf()) { it.isNotBlank() } + val currentUsers = currentUserIdentifiers + .filter(String::isNotBlank) + .mapTo(mutableSetOf()) { it.lowercase() } val unreadCanonicalIDs = unreadConversationIDs .mapTo(mutableSetOf()) { canonicalize(it).lowercase() } + val unreadCountsByCanonicalID = persistedUnreadCounts.entries + .groupingBy { canonicalize(it.key).lowercase() } + .fold(0) { total, entry -> total + entry.value } val aliasesByCanonicalID = linkedMapOf>() val messagesByCanonicalID = linkedMapOf>() + val displayCanonicalIDByNormalized = linkedMapOf() privateChats.forEach { (sourceID, messages) -> val canonicalID = canonicalize(sourceID) + val normalizedID = canonicalID.lowercase() + displayCanonicalIDByNormalized.putIfAbsent(normalizedID, canonicalID) aliasesByCanonicalID - .getOrPut(canonicalID) { linkedSetOf() } + .getOrPut(normalizedID) { linkedSetOf() } .add(sourceID) messagesByCanonicalID - .getOrPut(canonicalID) { mutableListOf() } + .getOrPut(normalizedID) { mutableListOf() } .addAll(messages) } - return messagesByCanonicalID.mapNotNull { (conversationID, sourceMessages) -> + return messagesByCanonicalID.mapNotNull { (normalizedConversationID, sourceMessages) -> + val conversationID = + displayCanonicalIDByNormalized.getValue(normalizedConversationID) val messages = sourceMessages.distinctBy { it.id } if (messages.isEmpty()) return@mapNotNull null @@ -59,18 +76,22 @@ internal fun buildConversationSummaries( compareBy(::activityOrder).thenBy { it.id } ) ?: return@mapNotNull null val incoming = messages.filterNot { - it.sender in currentUsers || it.sender == "system" + it.sender.lowercase() 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 persistedUnreadCount = unreadCountsByCanonicalID[conversationID.lowercase()] ?: 0 + val unreadCount = maxOf( + persistedUnreadCount, + if (canonicalUnread) { + incoming.count { !isMessageRead(it) }.coerceAtLeast(1) + } else { + 0 + } + ) + val aliases = aliasesByCanonicalID[normalizedConversationID].orEmpty() val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey val isNostrConversation = nostrPubkey != null || aliases.any(::isNostrConversationKey) || @@ -89,10 +110,13 @@ internal fun buildConversationSummaries( conversationID = conversationID, displayName = displayName, unreadCount = unreadCount, - latestMessageAt = latest.timestamp.time, + latestMessageAt = + PrivateMessageArrivalOrder.receivedAtOf(latest.id) ?: latest.timestamp.time, latestActivityOrder = activityOrder(latest), latestMessageType = latest.type, latestMessagePreview = latest.conversationPreview(), + latestMessageIsOutgoing = latest.sender.lowercase() in currentUsers, + latestDeliveryStatus = latest.deliveryStatus, transport = if (isNostrConversation) { DirectMessageTransport.NOSTR } else { @@ -109,6 +133,7 @@ internal fun sortConversationSummaries( conversations: List ): List = conversations.sortedWith( compareByDescending { it.isConnected } + .thenByDescending { it.isPinned } .thenByDescending { it.unreadCount > 0 } .thenByDescending { it.latestActivityOrder } .thenBy { it.displayName.lowercase() } diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index f9297afa..d5db98bb 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -441,7 +441,7 @@ class MediaSendingManager( } } - private fun handlePrivatePreparation( + private suspend fun handlePrivatePreparation( preparation: PrivateMediaPreparation, pending: PendingAutomaticPrivateMedia ) { @@ -599,7 +599,7 @@ class MediaSendingManager( } } - private fun commitPreparedPrivateFile( + private suspend fun commitPreparedPrivateFile( preparation: PrivateMediaPreparation.Ready, conversationID: String, recipientMeshPeerID: String, @@ -630,7 +630,14 @@ class MediaSendingManager( // Preparation already built and admitted the exact final packet. Map // progress before commit so the first asynchronous event cannot race us. - messageManager.addPrivateMessage(conversationID, msg) + if (!messageManager.addPrivateMessageDurably(conversationID, msg, forceRead = true)) { + Log.e(TAG, "Prepared private-media message could not be persisted; send aborted") + addPrivateMediaSystemMessage( + conversationID, + "Private media was not sent because the conversation could not be saved." + ) + return + } synchronized(transferMessageMap) { transferMessageMap[transferId] = msg.id messageTransferMap[msg.id] = transferId @@ -641,12 +648,17 @@ class MediaSendingManager( ) if (!preparation.transfer.commit()) { - messageManager.removeMessageById(msg.id) synchronized(transferMessageMap) { transferMessageMap.remove(transferId) messageTransferMap.remove(msg.id) } - Log.w(TAG, "Prepared private-media commit failed; local echo rolled back") + messageManager.updateMessageDeliveryStatus( + msg.id, + com.bitchat.android.model.DeliveryStatus.Failed( + "Prepared transfer could not be committed" + ) + ) + Log.w(TAG, "Prepared private-media commit failed; local echo marked failed") addPrivateMediaSystemMessage( conversationID, "Private media was not sent because the prepared transfer could not be committed." 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 18035967..6f7b8969 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -24,14 +24,17 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext @@ -39,6 +42,7 @@ 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.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -58,6 +62,7 @@ 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.model.DeliveryStatus import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette @@ -68,6 +73,7 @@ import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString import kotlinx.coroutines.launch +import kotlinx.coroutines.delay /** @@ -85,7 +91,6 @@ fun MeshPeerListSheet( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() @@ -97,6 +102,7 @@ fun MeshPeerListSheet( val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val conversations by viewModel.conversations.collectAsStateWithLifecycle() + val conversationStoreState by viewModel.conversationStoreState.collectAsStateWithLifecycle() val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle() val geohashPeopleCount = geohashPeople.size val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() @@ -123,6 +129,23 @@ fun MeshPeerListSheet( var pendingConversationDelete by remember { mutableStateOf(null) } + var conversationQuery by rememberSaveable { mutableStateOf("") } + val filteredConversations = remember(conversations, conversationQuery) { + val query = conversationQuery.trim() + if (query.isEmpty()) conversations else conversations.filter { conversation -> + conversation.displayName.contains(query, ignoreCase = true) || + conversation.latestMessagePreview.contains(query, ignoreCase = true) || + conversation.draft?.contains(query, ignoreCase = true) == true + } + } + val onlineConversations = remember(filteredConversations) { + filteredConversations.filter(ConversationSummary::isConnected) + } + val offlineConversations = remember(filteredConversations) { + filteredConversations.filterNot(ConversationSummary::isConnected) + } + val sheetScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } // Bottom sheet state val sheetState = rememberModalBottomSheetState( @@ -158,21 +181,172 @@ fun MeshPeerListSheet( else -> visibleConnectedPeers.count { it != viewModel.myPeerID } } - if (conversations.isNotEmpty()) { - item(key = "private_conversations_section") { - DirectMessagesSection( - conversations = conversations, + item(key = "private_conversations_header") { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_envelope, + title = stringResource(R.string.conversations), + modifier = Modifier.padding(top = 8.dp) + ) + } + + if (conversations.size >= CONVERSATION_SEARCH_THRESHOLD) { + item(key = "private_conversations_search") { + OutlinedTextField( + value = conversationQuery, + onValueChange = { conversationQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + placeholder = { + Text( + stringResource(R.string.search_conversations), + fontFamily = BitchatFontFamily + ) + }, + leadingIcon = { + Icon(Icons.Outlined.Search, contentDescription = null) + }, + trailingIcon = if (conversationQuery.isNotEmpty()) { + { + IconButton(onClick = { conversationQuery = "" }) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.clear) + ) + } + } + } else { + null + }, + shape = RoundedCornerShape(14.dp) + ) + } + } + + when { + conversationStoreState is + com.bitchat.android.services.ConversationStoreState.Loading && + conversations.isEmpty() -> { + item(key = "private_conversations_loading") { + ConversationSectionStatus( + icon = { CircularProgressIndicator(Modifier.size(20.dp)) }, + text = stringResource(R.string.loading_conversations) + ) + } + } + + conversationStoreState is + com.bitchat.android.services.ConversationStoreState.Error && + conversations.isEmpty() -> { + item(key = "private_conversations_error") { + ConversationSectionStatus( + icon = { + Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + text = stringResource(R.string.conversation_storage_error) + ) + } + } + + conversations.isEmpty() -> { + item(key = "private_conversations_empty") { + ConversationSectionStatus( + icon = { + Icon( + painterResource(R.drawable.ic_spec_envelope), + contentDescription = null + ) + }, + text = stringResource(R.string.no_conversations_yet) + ) + } + } + + filteredConversations.isEmpty() -> { + item(key = "private_conversations_no_results") { + ConversationSectionStatus( + icon = { + Icon(Icons.Outlined.SearchOff, contentDescription = null) + }, + text = stringResource(R.string.no_conversation_results) + ) + } + } + } + + if (onlineConversations.isNotEmpty()) { + item(key = "private_conversations_online_label") { + ConversationGroupLabel( + text = stringResource(R.string.online_conversations) + ) + } + itemsIndexed( + items = onlineConversations, + key = { _, conversation -> + "conversation:${conversation.conversationID}" + } + ) { index, conversation -> + ConversationSwipeItem( + conversation = conversation, directPeerIdentityIDs = directPeerIdentityIDs, wifiAwareIdentityIDs = wifiAwareIdentityIDs, viewModel = viewModel, + isFirst = index == 0, + isLast = index == onlineConversations.lastIndex, onPrivateChatStart = { conversationID -> viewModel.showPrivateChatSheet(conversationID) onDismiss() }, - onDeleteRequested = { conversation -> - pendingConversationDelete = conversation + onDeleteRequested = { pendingConversationDelete = it }, + onReadStateRequested = { item, isRead -> + sheetScope.launch { + viewModel.setConversationRead(item.conversationID, isRead) + } }, - modifier = Modifier.padding(top = 8.dp) + modifier = Modifier.animateItem() + ) + } + } + + if (offlineConversations.isNotEmpty()) { + item(key = "private_conversations_offline_label") { + ConversationGroupLabel( + text = stringResource(R.string.offline_conversations) + ) + } + itemsIndexed( + items = offlineConversations, + key = { _, conversation -> + "conversation:${conversation.conversationID}" + } + ) { index, conversation -> + ConversationSwipeItem( + conversation = conversation, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, + viewModel = viewModel, + isFirst = index == 0, + isLast = index == offlineConversations.lastIndex, + onPrivateChatStart = { conversationID -> + viewModel.showPrivateChatSheet(conversationID) + onDismiss() + }, + onDeleteRequested = { pendingConversationDelete = it }, + onReadStateRequested = { item, isRead -> + sheetScope.launch { + viewModel.setConversationRead(item.conversationID, isRead) + } + }, + modifier = Modifier.animateItem() ) } } @@ -299,10 +473,25 @@ fun MeshPeerListSheet( }, onClose = onDismiss, ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) } } pendingConversationDelete?.let { conversation -> + val deleteFailedMessage = stringResource(R.string.conversation_delete_failed) + val deletedMessage = stringResource( + R.string.conversation_deleted, + conversation.displayName + ) + val undoLabel = stringResource(R.string.undo) + val restoreFailedMessage = + stringResource(R.string.conversation_restore_failed) AlertDialog( onDismissRequest = { pendingConversationDelete = null }, icon = { @@ -329,8 +518,31 @@ fun MeshPeerListSheet( confirmButton = { TextButton( onClick = { - viewModel.deletePrivateConversation(conversation.conversationID) pendingConversationDelete = null + sheetScope.launch { + val deletion = viewModel.deletePrivateConversation( + conversation.conversationID + ) + if (deletion == null) { + snackbarHostState.showSnackbar( + message = deleteFailedMessage + ) + return@launch + } + val result = snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = undoLabel, + withDismissAction = true, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + if (!viewModel.restoreDeletedConversation(deletion)) { + snackbarHostState.showSnackbar( + restoreFailedMessage + ) + } + } + } } ) { Text( @@ -355,6 +567,7 @@ fun MeshPeerListSheet( /** Icon size for trailing actions on peer rows (matches settings glyph scale). */ private val PeerRowIconSize = 22.dp +private const val CONVERSATION_SEARCH_THRESHOLD = 8 @Composable private fun ChannelRow( @@ -693,15 +906,64 @@ fun PeopleSection( } } +@Composable +private fun ConversationSectionStatus( + icon: @Composable () -> Unit, + text: String +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = MaterialTheme.colorScheme.surface, + shape = AboutCardShape + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + icon() + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ConversationGroupLabel(text: String) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding + 4.dp) + .padding(top = 12.dp, bottom = 6.dp), + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DirectMessagesSection( - conversations: List, +private fun ConversationSwipeItem( + conversation: ConversationSummary, directPeerIdentityIDs: Set, wifiAwareIdentityIDs: Set, viewModel: ChatViewModel, + isFirst: Boolean, + isLast: Boolean, onPrivateChatStart: (String) -> Unit, onDeleteRequested: (ConversationSummary) -> Unit, + onReadStateRequested: (ConversationSummary, Boolean) -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme @@ -710,131 +972,153 @@ private fun DirectMessagesSection( val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() - - Column(modifier = modifier) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_envelope, - title = stringResource(R.string.conversations) + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + 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 + val isVerified = fingerprint != null && fingerprint in verifiedFingerprints + val dismissState = rememberSwipeToDismissBoxState() + val shape = RoundedCornerShape( + topStart = if (isFirst) 14.dp else 0.dp, + topEnd = if (isFirst) 14.dp else 0.dp, + bottomStart = if (isLast) 14.dp else 0.dp, + bottomEnd = if (isLast) 14.dp else 0.dp + ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = colorScheme.surface, - shape = AboutCardShape - ) { - AnimatedRowColumn( - items = conversations, - key = { it.conversationID } - ) { index, conversation -> - Column { - if (index > 0) SheetCardDivider() + LaunchedEffect(dismissState.currentValue, conversation.conversationID) { + when (dismissState.currentValue) { + SwipeToDismissBoxValue.StartToEnd -> { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onReadStateRequested(conversation, conversation.unreadCount > 0) + dismissState.reset() + } - 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) - onDeleteRequested(conversation) - dismissState.reset() - } - } - SwipeToDismissBox( - state = dismissState, - enableDismissFromStartToEnd = true, - enableDismissFromEndToStart = true, - backgroundContent = { - val alignment = when (dismissState.dismissDirection) { - SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart - else -> Alignment.CenterEnd - } - Row( - modifier = Modifier - .fillMaxSize() - .background(colorScheme.errorContainer) - .padding(horizontal = SheetRowHorizontal), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = when (alignment) { - Alignment.CenterStart -> Arrangement.Start - else -> Arrangement.End + SwipeToDismissBoxValue.EndToStart -> { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onDeleteRequested(conversation) + dismissState.reset() + } + + SwipeToDismissBoxValue.Settled -> Unit + } + } + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .clip(shape), + color = colorScheme.surface, + shape = shape + ) { + Column { + if (!isFirst) SheetCardDivider() + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = true, + enableDismissFromEndToStart = true, + backgroundContent = { + val markRead = conversation.unreadCount > 0 + val startToEnd = + dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd + Row( + modifier = Modifier + .fillMaxSize() + .background( + if (startToEnd) { + colorScheme.secondaryContainer + } else { + colorScheme.errorContainer } - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = deleteDescription, - tint = colorScheme.onErrorContainer, - modifier = Modifier.size(PeerRowIconSize) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.delete), - fontFamily = BitchatFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onErrorContainer - ) - } + ) + .padding(horizontal = SheetRowHorizontal), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (startToEnd) { + Arrangement.Start + } else { + Arrangement.End } ) { - ConversationRow( - conversation = conversation, - directPeerIdentityIDs = directPeerIdentityIDs, - wifiAwareIdentityIDs = wifiAwareIdentityIDs, - viewModel = viewModel, - isFavorite = isFavorite, - theyFavoritedUs = theyFavoritedUs, - deleteDescription = deleteDescription, - onClick = { - onPrivateChatStart(conversation.conversationID) + Icon( + imageVector = if (startToEnd) { + if (markRead) Icons.Outlined.MarkEmailRead + else Icons.Outlined.MarkEmailUnread + } else { + Icons.Outlined.Delete }, - onToggleFavorite = { - viewModel.toggleFavorite(favoriteTargetID) + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (startToEnd) { + stringResource( + if (markRead) R.string.mark_read else R.string.mark_unread + ) + } else { + stringResource(R.string.delete) }, - onDeleteRequested = { - onDeleteRequested(conversation) - } + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold + ) ) } } + ) { + ConversationRow( + conversation = conversation, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, + viewModel = viewModel, + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, + isVerified = isVerified, + deleteDescription = deleteDescription, + onClick = { onPrivateChatStart(conversation.conversationID) }, + onToggleFavorite = { viewModel.toggleFavorite(favoriteTargetID) }, + onTogglePinned = { + viewModel.toggleConversationPinned(conversation.conversationID) + }, + onToggleMuted = { + viewModel.toggleConversationMuted(conversation.conversationID) + }, + onReadStateRequested = { + onReadStateRequested(conversation, conversation.unreadCount > 0) + }, + onDeleteRequested = { onDeleteRequested(conversation) } + ) } } } @@ -848,13 +1132,18 @@ private fun ConversationRow( viewModel: ChatViewModel, isFavorite: Boolean, theyFavoritedUs: Boolean, + isVerified: Boolean, deleteDescription: String, onClick: () -> Unit, onToggleFavorite: () -> Unit, + onTogglePinned: () -> Unit, + onToggleMuted: () -> Unit, + onReadStateRequested: () -> Unit, onDeleteRequested: () -> Unit ) { val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme + var showActions by remember { mutableStateOf(false) } val liveIdentityIDs = conversation.identityAliases + listOfNotNull(conversation.connectedPeerID?.lowercase()) val isWifiAware = liveIdentityIDs.any(wifiAwareIdentityIDs::contains) @@ -868,15 +1157,38 @@ private fun ConversationRow( 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 basePreview = when { + !conversation.draft.isNullOrBlank() -> stringResource( + R.string.conversation_draft_preview, + conversation.draft + ) + conversation.latestMessageType == BitchatMessageType.Image -> + stringResource(R.string.notification_sent_image) + conversation.latestMessageType == BitchatMessageType.Audio -> + stringResource(R.string.notification_sent_voice) + conversation.latestMessageType == BitchatMessageType.File -> + conversation.latestMessagePreview + .takeIf(String::isNotBlank) + ?.let { "📎 $it" } + ?: stringResource(R.string.notification_sent_file) + else -> conversation.latestMessagePreview.ifBlank { "…" } } + val messagePreview = if ( + conversation.latestMessageIsOutgoing && conversation.draft.isNullOrBlank() + ) { + stringResource(R.string.conversation_you_preview, basePreview) + } else { + basePreview + } + val deliveryIndicator = when (conversation.latestDeliveryStatus) { + DeliveryStatus.Sending -> stringResource(R.string.status_sending) + DeliveryStatus.Sent -> stringResource(R.string.status_sent) + is DeliveryStatus.Delivered -> stringResource(R.string.status_delivered) + is DeliveryStatus.Read -> stringResource(R.string.status_read) + is DeliveryStatus.Failed -> stringResource(R.string.status_failed) + is DeliveryStatus.PartiallyDelivered -> stringResource(R.string.status_pending) + null -> "" + }.takeIf { conversation.latestMessageIsOutgoing } val presenceDescription = when { conversation.isConnected -> connectionDescription conversation.transport == DirectMessageTransport.NOSTR -> @@ -888,22 +1200,56 @@ private fun ConversationRow( ?: 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() + val relativeTime by produceState( + initialValue = conversationRelativeTime(conversation.latestMessageAt), + conversation.latestMessageAt + ) { + while (true) { + value = conversationRelativeTime(conversation.latestMessageAt) + delay(DateUtils.MINUTE_IN_MILLIS) + } } + val unreadDescription = if (conversation.unreadCount > 0) { + stringResource(R.string.conversation_unread_count, conversation.unreadCount) + } else { + stringResource(R.string.conversation_read) + } + val pinDescription = stringResource( + if (conversation.isPinned) R.string.unpin_conversation else R.string.pin_conversation + ) + val muteDescription = stringResource( + if (conversation.isMuted) R.string.unmute_conversation else R.string.mute_conversation + ) + val readActionDescription = stringResource( + if (conversation.unreadCount > 0) R.string.mark_read else R.string.mark_unread + ) Row( modifier = Modifier .fillMaxWidth() .background(colorScheme.surface) - .semantics { + .semantics(mergeDescendants = true) { + contentDescription = listOf( + conversation.displayName, + unreadDescription, + messagePreview, + relativeTime, + presenceDescription + ).joinToString(", ") stateDescription = presenceDescription customActions = listOf( + CustomAccessibilityAction(readActionDescription) { + onReadStateRequested() + true + }, + CustomAccessibilityAction(pinDescription) { + onTogglePinned() + true + }, + CustomAccessibilityAction(muteDescription) { + onToggleMuted() + true + }, CustomAccessibilityAction(deleteDescription) { onDeleteRequested() true @@ -913,43 +1259,78 @@ private fun ConversationRow( .clickable(onClick = onClick) .padding( horizontal = SheetRowHorizontal, - vertical = SheetRowVertical + vertical = 10.dp ), verticalAlignment = Alignment.CenterVertically ) { Box( - modifier = Modifier.size(SheetRowLeadingSlot), - contentAlignment = Alignment.Center + modifier = Modifier.size(42.dp), + contentAlignment = Alignment.Center, ) { - when { - conversation.isConnected -> Icon( - painter = painterResource( - conversationTransportIcon( - isReachedOverInternet = false, - isWifiAware = isWifiAware, - isDirect = isDirect - ) + Box( + modifier = Modifier + .size(38.dp) + .background(assignedColor.copy(alpha = 0.16f), CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = baseNameRaw + .trim() + .firstOrNull() + ?.uppercase() + ?: "#", + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold ), - 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 + color = assignedColor ) } + + Surface( + modifier = Modifier + .size(18.dp) + .align(Alignment.BottomEnd), + shape = CircleShape, + color = colorScheme.surface, + tonalElevation = 1.dp + ) { + Box(contentAlignment = Alignment.Center) { + when { + conversation.isConnected -> Icon( + painter = painterResource( + conversationTransportIcon( + isReachedOverInternet = false, + isWifiAware = isWifiAware, + isDirect = isDirect + ) + ), + contentDescription = connectionDescription, + modifier = Modifier.size(13.dp), + tint = colorScheme.primary + ) + + conversation.transport == DirectMessageTransport.NOSTR -> Icon( + painter = painterResource(R.drawable.ic_spec_globe), + contentDescription = stringResource( + R.string.offline_reachable_via_nostr + ), + modifier = Modifier.size(13.dp), + tint = palette.accentPurple + ) + + else -> Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.offline_not_in_mesh), + modifier = Modifier.size(11.dp), + tint = palette.textTertiary + ) + } + } + } } - Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Row( @@ -958,13 +1339,14 @@ private fun ConversationRow( ) { Text( text = truncateNickname(baseNameRaw), - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = if (conversation.unreadCount > 0) { - FontWeight.Bold - } else { - FontWeight.Medium - }, + style = MaterialTheme.typography.bodyLarge.copy( + fontFamily = BitchatFontFamily, + fontWeight = if (conversation.unreadCount > 0) { + FontWeight.Bold + } else { + FontWeight.Medium + } + ), color = assignedColor, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -972,27 +1354,83 @@ private fun ConversationRow( if (suffix.isNotEmpty()) { Text( text = suffix, - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium + ), color = assignedColor.copy(alpha = SUFFIX_ALPHA) ) } + if (isVerified) { + Icon( + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(15.dp), + tint = colorScheme.primary + ) + } + if (conversation.isPinned) { + Icon( + Icons.Filled.PushPin, + contentDescription = pinDescription, + modifier = Modifier.size(14.dp), + tint = palette.textTertiary + ) + } + if (conversation.isMuted) { + Icon( + Icons.Outlined.NotificationsOff, + contentDescription = muteDescription, + modifier = Modifier.size(14.dp), + tint = palette.textTertiary + ) + } } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { Text( text = messagePreview, - fontFamily = BitchatFontFamily, - fontSize = 11.sp, - color = palette.textTertiary, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily, + fontWeight = if (!conversation.draft.isNullOrBlank()) { + FontWeight.Medium + } else { + FontWeight.Normal + } + ), + color = if (!conversation.draft.isNullOrBlank()) { + palette.accentOrange + } else { + palette.textTertiary + }, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) + modifier = Modifier.weight(1f) ) + deliveryIndicator?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = BitchatFontFamily + ), + color = if (conversation.latestDeliveryStatus is DeliveryStatus.Failed) { + colorScheme.error + } else { + palette.textTertiary + }, + maxLines = 1 + ) + } Text( - text = " · $relativeTime", - fontFamily = BitchatFontFamily, - fontSize = 10.sp, + text = stringResource( + R.string.conversation_preview_timestamp, + relativeTime + ), + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = BitchatFontFamily + ), color = palette.textTertiary, maxLines = 1 ) @@ -1005,11 +1443,9 @@ private fun ConversationRow( modifier = Modifier.padding(start = 4.dp) ) - Box( - modifier = Modifier - .size(36.dp) - .clickable(onClick = onToggleFavorite), - contentAlignment = Alignment.Center + IconButton( + onClick = onToggleFavorite, + modifier = Modifier.size(48.dp) ) { Icon( painter = painterResource( @@ -1034,9 +1470,89 @@ private fun ConversationRow( } ) } + + Box { + IconButton( + onClick = { showActions = true }, + modifier = Modifier.size(48.dp) + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.conversation_actions), + tint = palette.textTertiary + ) + } + DropdownMenu( + expanded = showActions, + onDismissRequest = { showActions = false } + ) { + DropdownMenuItem( + text = { Text(pinDescription) }, + leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, + onClick = { + showActions = false + onTogglePinned() + } + ) + DropdownMenuItem( + text = { Text(muteDescription) }, + leadingIcon = { + Icon(Icons.Outlined.NotificationsOff, contentDescription = null) + }, + onClick = { + showActions = false + onToggleMuted() + } + ) + DropdownMenuItem( + text = { Text(readActionDescription) }, + leadingIcon = { + Icon( + if (conversation.unreadCount > 0) { + Icons.Outlined.MarkEmailRead + } else { + Icons.Outlined.MarkEmailUnread + }, + contentDescription = null + ) + }, + onClick = { + showActions = false + onReadStateRequested() + } + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.delete), + color = colorScheme.error + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.Delete, + contentDescription = null, + tint = colorScheme.error + ) + }, + onClick = { + showActions = false + onDeleteRequested() + } + ) + } + } } } +private fun conversationRelativeTime(timestamp: Long): String = + DateUtils.getRelativeTimeSpanString( + timestamp, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + @Composable private fun PeerItem( peerID: String, @@ -1435,10 +1951,10 @@ fun PrivateChatSheet( // Input section. No divider here: ChatInputSection draws its own fade and // hairline. - var messageText by remember { + var messageText by remember(peerID) { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( - "" + viewModel.conversationDraft(peerID) ) ) } @@ -1447,13 +1963,19 @@ fun PrivateChatSheet( messageText = messageText, onMessageTextChange = { newText -> messageText = newText + viewModel.setConversationDraft(peerID, newText.text) viewModel.updateMentionSuggestions(newText.text) }, onSend = { if (messageText.text.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.text.trim()) - messageText = androidx.compose.ui.text.input.TextFieldValue("") - forceScrollToBottom = !forceScrollToBottom + viewModel.sendMessage(messageText.text.trim()) { accepted -> + if (accepted) { + messageText = + androidx.compose.ui.text.input.TextFieldValue("") + viewModel.setConversationDraft(peerID, "") + forceScrollToBottom = !forceScrollToBottom + } + } } }, onSendVoiceNote = { peer, channel, path -> 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 e3be65fe..31e7d1af 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -111,6 +111,40 @@ class MessageManager(private val state: ChatState) { false } if (!accepted) return + publishAcceptedPrivateMessage(conversationID, message, forceRead = false) + } + + /** + * Adds a private message only after its database transaction has completed. + * + * Outgoing sends and non-mesh transports use this path so the local echo is never shown or + * transmitted unless it can survive an immediate process death. + */ + suspend fun addPrivateMessageDurably( + peerID: String, + message: BitchatMessage, + forceRead: Boolean = false + ): Boolean { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val accepted = try { + com.bitchat.android.services.AppStateStore.addPrivateMessageDurably( + peerID = conversationID, + msg = message, + forceRead = forceRead + ) + } catch (_: Exception) { + false + } + if (!accepted) return false + publishAcceptedPrivateMessage(conversationID, message, forceRead) + return true + } + + private fun publishAcceptedPrivateMessage( + conversationID: String, + message: BitchatMessage, + forceRead: Boolean + ) { val currentPrivateChats = state.getPrivateChatsValue().toMutableMap() if (!currentPrivateChats.containsKey(conversationID)) { currentPrivateChats[conversationID] = mutableListOf() @@ -125,6 +159,7 @@ class MessageManager(private val state: ChatState) { val selectedConversationID = state.getSelectedPrivateChatPeerValue() ?.let(ContactDirectory::canonicalConversationId) if ( + !forceRead && selectedConversationID != conversationID && message.sender != state.getNicknameValue() ) { @@ -147,14 +182,7 @@ class MessageManager(private val state: ChatState) { false } if (!accepted) return - val currentPrivateChats = state.getPrivateChatsValue().toMutableMap() - if (!currentPrivateChats.containsKey(conversationID)) { - currentPrivateChats[conversationID] = mutableListOf() - } - val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() - chatMessages.add(message) - currentPrivateChats[conversationID] = chatMessages - state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) + publishAcceptedPrivateMessage(conversationID, message, forceRead = true) } fun clearPrivateMessages(peerID: String) { @@ -259,12 +287,19 @@ class MessageManager(private val state: ChatState) { is DeliveryStatus.PartiallyDelivered -> 3 is DeliveryStatus.Delivered -> 4 is DeliveryStatus.Read -> 5 - is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering + is DeliveryStatus.Failed -> 0 } private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { - // Never downgrade (e.g., Read -> Delivered). Keep the higher priority. - return if (statusPriority(new) >= statusPriority(old)) new else old + // A send failure may replace an in-flight state, but never a confirmed delivery/read. + return when { + new is DeliveryStatus.Failed && + old !is DeliveryStatus.Delivered && + old !is DeliveryStatus.Read -> new + old is DeliveryStatus.Failed -> new + statusPriority(new) >= statusPriority(old) -> new + else -> old + } } fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) { diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index f15ada44..b273d923 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -1,19 +1,31 @@ package com.bitchat.android.ui +import android.Manifest import android.app.NotificationChannel -import android.app.NotificationManager +import android.app.NotificationManager as AndroidNotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.content.LocusIdCompat +import androidx.core.graphics.drawable.IconCompat import com.bitchat.android.MainActivity import com.bitchat.android.R +import com.bitchat.android.service.ConversationNotificationReceiver import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ConversationListPreferences import com.bitchat.android.util.NotificationIntervalManager +import java.util.Collections +import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap /** @@ -43,6 +55,7 @@ class NotificationManager( private const val SUMMARY_NOTIFICATION_ID = 999 private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998 private const val ACTIVE_PEERS_NOTIFICATION_ID = 997 + private const val MAX_MESSAGES_IN_NOTIFICATION = 25 private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS // Intent extras for notification handling @@ -51,9 +64,33 @@ class NotificationManager( const val EXTRA_PEER_ID = "peer_id" const val EXTRA_SENDER_NICKNAME = "sender_nickname" const val EXTRA_GEOHASH = "geohash" + const val ACTION_REPLY_TO_CONVERSATION = + "com.bitchat.android.action.REPLY_TO_CONVERSATION" + const val ACTION_MARK_CONVERSATION_READ = + "com.bitchat.android.action.MARK_CONVERSATION_READ" + const val KEY_TEXT_REPLY = "conversation_reply_text" + + private val liveManagers: MutableSet = + Collections.newSetFromMap(WeakHashMap()) + + /** + * Synchronizes notification action receivers with every manager instance in this process. + * Without this, an old in-memory MessagingStyle history could reappear on the next DM. + */ + fun acknowledgeConversation(context: Context, conversationID: String) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val managers = synchronized(liveManagers) { liveManagers.toList() } + managers.forEach { it.clearNotificationsForSender(canonicalID) } + if (managers.isEmpty()) { + NotificationManagerCompat.from(context).cancel(canonicalID.hashCode()) + } + } } - private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val systemNotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as AndroidNotificationManager + private val conversationPreferences = + ConversationListPreferences.getInstance(context.applicationContext) // Track pending notifications per sender to enable grouping private val pendingNotifications = ConcurrentHashMap>() @@ -88,15 +125,17 @@ class NotificationManager( ) init { + synchronized(liveManagers) { liveManagers.add(this) } createNotificationChannel() } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // DM notifications channel - val dmName = "Direct Messages" - val dmDescriptionText = "Notifications for private messages from other users" - val dmImportance = NotificationManager.IMPORTANCE_HIGH + val dmName = context.getString(R.string.notification_channel_direct_messages) + val dmDescriptionText = + context.getString(R.string.notification_channel_direct_messages_description) + val dmImportance = AndroidNotificationManager.IMPORTANCE_HIGH val dmChannel = NotificationChannel(CHANNEL_ID, dmName, dmImportance).apply { description = dmDescriptionText enableVibration(true) @@ -105,9 +144,10 @@ class NotificationManager( systemNotificationManager.createNotificationChannel(dmChannel) // Geohash notifications channel - val geohashName = "Geohash Chats" - val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels" - val geohashImportance = NotificationManager.IMPORTANCE_HIGH + val geohashName = context.getString(R.string.notification_channel_geohash) + val geohashDescriptionText = + context.getString(R.string.notification_channel_geohash_description) + val geohashImportance = AndroidNotificationManager.IMPORTANCE_HIGH val geohashChannel = NotificationChannel(GEOHASH_CHANNEL_ID, geohashName, geohashImportance).apply { description = geohashDescriptionText enableVibration(true) @@ -146,6 +186,10 @@ class NotificationManager( */ fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) { val conversationID = ContactDirectory.canonicalConversationId(senderPeerID) + if (conversationPreferences.isMuted(conversationID)) { + Log.d(TAG, "Skipping muted conversation notification") + return + } // Only show notifications if app is in background OR user is not viewing this specific chat val shouldNotify = isAppInBackground || (!isAppInBackground && currentPrivateChatPeer != conversationID) @@ -219,6 +263,12 @@ class NotificationManager( .setName(latestNotification.senderNickname) .setKey(senderPeerID) .build() + val shortcutID = conversationShortcutID(senderPeerID) + publishConversationShortcut( + shortcutID = shortcutID, + person = person, + contentIntent = intent + ) // Build notification content val contentText = if (messageCount == 1) { @@ -242,47 +292,110 @@ class NotificationManager( .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .addPerson(person) + .setShortcutId(shortcutID) + .setLocusId(LocusIdCompat(shortcutID)) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setShowWhen(true) .setWhen(latestNotification.timestamp) + val markReadIntent = Intent(context, ConversationNotificationReceiver::class.java).apply { + action = ACTION_MARK_CONVERSATION_READ + putExtra(EXTRA_PEER_ID, senderPeerID) + } + val markReadPendingIntent = PendingIntent.getBroadcast( + context, + NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 1, + markReadIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val replyIntent = Intent(context, ConversationNotificationReceiver::class.java).apply { + action = ACTION_REPLY_TO_CONVERSATION + putExtra(EXTRA_PEER_ID, senderPeerID) + putExtra(EXTRA_SENDER_NICKNAME, latestNotification.senderNickname) + } + val replyPendingIntent = PendingIntent.getBroadcast( + context, + NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 2, + replyIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel(context.getString(R.string.notification_reply)) + .build() + builder + .addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_notification, + context.getString(R.string.notification_mark_read), + markReadPendingIntent + ).build() + ) + .addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_notification, + context.getString(R.string.notification_reply), + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .build() + ) + .setPublicVersion( + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_private_message)) + .setContentText(context.getString(R.string.notification_content_hidden)) + .build() + ) + // Add to notification group if we have multiple senders if (pendingNotifications.size > 1) { builder.setGroup(GROUP_KEY_DM) } - // Add style for multiple messages - if (messageCount > 1) { - val style = NotificationCompat.InboxStyle() - .setBigContentTitle(contentTitle) - - // Show last few messages in expanded view - notifications.takeLast(5).forEach { notif -> - style.addLine(notif.messageContent) - } - - if (messageCount > 5) { - val extra = messageCount - 5 - style.setSummaryText(context.resources.getQuantityString( - R.plurals.notification_and_more, extra, extra - )) - } - - builder.setStyle(style) - } else { - // Single message - use BigTextStyle for long messages - builder.setStyle( - NotificationCompat.BigTextStyle() - .bigText(latestNotification.messageContent) + val self = Person.Builder() + .setName(context.getString(R.string.you)) + .setKey("bitchat-self") + .build() + val messagingStyle = NotificationCompat.MessagingStyle(self) + .setGroupConversation(false) + notifications.takeLast(MAX_MESSAGES_IN_NOTIFICATION).forEach { notification -> + messagingStyle.addMessage( + notification.messageContent, + notification.timestamp, + person ) } + builder.setStyle(messagingStyle) // Use sender peer ID hash as notification ID to group messages from same sender val notificationId = senderPeerID.hashCode() - notificationManager.notify(notificationId, builder.build()) + notifySafely(notificationId, builder.build()) Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") } + private fun conversationShortcutID(conversationID: String): String = + "dm_" + java.util.UUID.nameUUIDFromBytes( + conversationID.lowercase().toByteArray(Charsets.UTF_8) + ).toString() + + private fun publishConversationShortcut( + shortcutID: String, + person: Person, + contentIntent: Intent + ) { + val shortcut = ShortcutInfoCompat.Builder(context, shortcutID) + .setShortLabel(person.name?.toString()?.take(40).orEmpty()) + .setLongLived(true) + .setPerson(person) + .setLocusId(LocusIdCompat(shortcutID)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_notification)) + .setIntent(Intent(contentIntent).apply { action = Intent.ACTION_VIEW }) + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } + fun showVerificationNotification(title: String, body: String, peerID: String? = null) { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -311,7 +424,10 @@ class NotificationManager( .setShowWhen(true) .setWhen(System.currentTimeMillis()) - notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build()) + notifySafely( + (System.currentTimeMillis() and 0x7FFFFFFF).toInt(), + builder.build() + ) } private fun showNotificationForActivePeers(peersSize: Int) { @@ -346,7 +462,7 @@ class NotificationManager( .setShowWhen(true) .setWhen(System.currentTimeMillis()) - notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build()) + notifySafely(ACTIVE_PEERS_NOTIFICATION_ID, builder.build()) Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID") } private fun showSummaryNotification() { @@ -398,7 +514,7 @@ class NotificationManager( builder.setStyle(style) - notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build()) + notifySafely(SUMMARY_NOTIFICATION_ID, builder.build()) Log.d(TAG, "Displayed summary notification for $senderCount senders") } @@ -431,6 +547,15 @@ class NotificationManager( Log.d(TAG, "Cleared notifications for conversation: $conversationID") } + fun removeConversationShortcut(conversationID: String) { + val shortcutIDs = listOf(conversationShortcutID(conversationID)) + ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutIDs) + ShortcutManagerCompat.removeLongLivedShortcuts( + context, + shortcutIDs + ) + } + /** * Show a notification for a geohash message with mention or first message */ @@ -559,7 +684,7 @@ class NotificationManager( // Use geohash hash as notification ID to group messages from same geohash val notificationId = 3000 + geohash.hashCode() - notificationManager.notify(notificationId, builder.build()) + notifySafely(notificationId, builder.build()) Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId") } @@ -626,7 +751,7 @@ class NotificationManager( builder.setStyle(style) - notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build()) + notifySafely(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build()) Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations") } @@ -767,7 +892,7 @@ class NotificationManager( // Use a special notification ID for mesh mentions val notificationId = 4000 // Different from DM and geohash IDs - notificationManager.notify(notificationId, builder.build()) + notifySafely(notificationId, builder.build()) Log.d(TAG, "Displayed mesh mention notification: $contentTitle") } @@ -799,13 +924,37 @@ class NotificationManager( /** * Clear all pending notifications */ - fun clearAllNotifications() { + fun clearAllNotifications(removeConversationShortcuts: Boolean = false) { pendingNotifications.clear() notificationManager.cancelAll() pendingGeohashNotifications.clear() + if (removeConversationShortcuts) { + val shortcutIDs = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id } + ShortcutManagerCompat.removeAllDynamicShortcuts(context) + if (shortcutIDs.isNotEmpty()) { + ShortcutManagerCompat.removeLongLivedShortcuts(context, shortcutIDs) + } + } Log.d(TAG, "Cleared all notifications") } + private fun notifySafely(notificationID: Int, notification: android.app.Notification) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + try { + notificationManager.notify(notificationID, notification) + } catch (error: SecurityException) { + Log.w(TAG, "Notification permission was revoked: ${error.message}") + } + } + /** * Get pending notification count for UI badging */ diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index ee28a534..12678625 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -134,6 +134,50 @@ class PrivateChatManager( return true } + /** + * Persists the local echo before handing the payload to a transport. + * + * A failed database write deliberately aborts the send: otherwise the remote peer could + * receive a message that disappears from the sender's conversation after process death. + */ + suspend fun sendPrivateMessageDurably( + content: String, + peerID: String, + recipientNickname: String?, + senderNickname: String?, + myPeerID: String, + onSendMessage: (String, String, String, String) -> Unit + ): Boolean { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + if (isPeerBlocked(peerID)) { + val systemMessage = BitchatMessage( + sender = "system", + content = "cannot send message to $recipientNickname: user is blocked.", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(systemMessage) + return false + } + + val message = BitchatMessage( + sender = senderNickname ?: myPeerID, + content = content, + timestamp = Date(), + isRelay = false, + isPrivate = true, + recipientNickname = recipientNickname, + senderPeerID = myPeerID, + deliveryStatus = DeliveryStatus.Sending + ) + + if (!messageManager.addPrivateMessageDurably(conversationID, message, forceRead = true)) { + return false + } + onSendMessage(content, conversationID, recipientNickname ?: "", message.id) + return true + } + // MARK: - Peer Management fun isPeerBlocked(peerID: String): Boolean { @@ -375,6 +419,51 @@ class PrivateChatManager( } } + /** + * Durable admission for transports that do not pass through the mesh admission pipeline. + * + * Nostr acknowledgements are emitted by the caller only after this returns true, which lets a + * failed write be retried rather than silently acknowledging a message that was never saved. + */ + suspend fun handleIncomingPrivateMessageDurably( + message: BitchatMessage, + suppressUnread: Boolean, + origin: PrivateMessageOrigin + ): Boolean { + val senderPeerID = message.senderPeerID + val conversationID = senderPeerID + ?.let(ContactDirectory::canonicalConversationId) + ?: state.getSelectedPrivateChatPeerValue() + ?: return false + + if (senderPeerID != null && isPeerBlocked(senderPeerID)) return false + messageManager.initializePrivateChat(conversationID) + + val shouldPersistHere = origin == PrivateMessageOrigin.NOSTR || senderPeerID == null + if (shouldPersistHere) { + val accepted = messageManager.addPrivateMessageDurably( + peerID = conversationID, + message = message, + forceRead = suppressUnread || !trackUnreadMessages + ) + if (!accepted) return false + } + + if ( + senderPeerID != null && + trackUnreadMessages && + !suppressUnread && + state.getSelectedPrivateChatPeerValue() != conversationID + ) { + val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() } + unreadList.add(message) + val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() + currentUnread.add(conversationID) + state.setUnreadPrivateMessages(currentUnread) + } + return true + } + /** * Send read receipts for all unread messages from a specific peer * Called when the user focuses on a private chat diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 883e14d0..7017cec8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,13 +74,46 @@ 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? + Conversations + Offline · not in mesh + Offline from mesh · Nostr available + Delete + Delete conversation + Delete conversation? + Delete your conversation with %1$s from this device? + Search conversations + Clear + Loading private conversations… + Private conversations could not be loaded + Private conversations will appear here + No matching conversations + ONLINE + OFFLINE + Mark as read + Mark as unread + Pin conversation + Unpin conversation + Mute conversation + Unmute conversation + Conversation actions + %1$d unread messages + Read + Draft: %1$s + You: %1$s + · %1$s + Conversation could not be deleted + Deleted conversation with %1$s + Undo + Conversation could not be restored + Reply + Mark read + New private message + Open bitchat to view it + Direct Messages + Notifications for private messages from other users + Geohash Chats + Notifications for mentions and messages in geohash location channels + You Location notes Teleported Tor status diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index f756447a..634e053f 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -2,6 +2,8 @@ package com.bitchat.android.nostr import android.os.Build import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ConversationRepository +import com.bitchat.android.services.InMemoryConversationStorageCipher import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.DataManager @@ -30,6 +32,7 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.util.UUID @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) @@ -37,17 +40,31 @@ import org.robolectric.annotation.Config class NostrDirectMessageHandlerTest { private val gson = Gson() private lateinit var scope: CoroutineScope + private lateinit var conversationRepository: ConversationRepository + private lateinit var conversationDatabaseName: String @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) AppStateStore.clear() + conversationDatabaseName = "nostr-dm-${UUID.randomUUID()}.db" + conversationRepository = ConversationRepository( + context = RuntimeEnvironment.getApplication(), + dispatcher = Dispatchers.Unconfined, + databaseName = conversationDatabaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + AppStateStore.setConversationRepositoryForTest(conversationRepository) } @After fun tearDown() { AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(null) + conversationRepository.closeForTest() + RuntimeEnvironment.getApplication() + .deleteDatabase(conversationDatabaseName) scope.cancel() Dispatchers.resetMain() } 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 cd94e90d..44c32826 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -194,6 +194,68 @@ class AppStateStoreTest { assertTrue(status is DeliveryStatus.Read) } + @Test + fun `in flight message can become failed without overwriting confirmed delivery`() { + val sending = BitchatMessage( + id = "send-failure", + sender = "me", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = DeliveryStatus.Sending + ) + AppStateStore.addPrivateMessage("peer-a", sending) + + AppStateStore.updatePrivateMessageStatus( + sending.id, + DeliveryStatus.Failed("network unavailable") + ) + assertTrue( + AppStateStore.privateMessages.value + .getValue("peer-a") + .single() + .deliveryStatus is DeliveryStatus.Failed + ) + + AppStateStore.updatePrivateMessageStatus( + sending.id, + DeliveryStatus.Delivered("alice", Date(2)) + ) + AppStateStore.updatePrivateMessageStatus( + sending.id, + DeliveryStatus.Failed("late timeout") + ) + assertTrue( + AppStateStore.privateMessages.value + .getValue("peer-a") + .single() + .deliveryStatus is DeliveryStatus.Delivered + ) + } + + @Test + fun `closing a conversation releases full payload history but keeps its summary`() { + repeat(3) { index -> + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "history-$index", + sender = "alice", + content = "message $index", + timestamp = Date(index.toLong()), + isPrivate = true + ) + ) + } + + AppStateStore.releasePrivateConversationHistory("peer-a") + + assertEquals( + listOf("history-2"), + AppStateStore.privateMessages.value.getValue("peer-a").map { it.id } + ) + } + @Test fun `long lived process applies the same bounded private history policy`() { AppStateStore.setNickname("me") diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt index 4f768956..30ff5106 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt @@ -1,5 +1,6 @@ package com.bitchat.android.services +import android.content.ContentValues import android.content.Context import androidx.test.core.app.ApplicationProvider import com.bitchat.android.model.BitchatMessage @@ -9,6 +10,9 @@ import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -16,18 +20,21 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.util.Date import java.util.UUID +import java.io.File @RunWith(RobolectricTestRunner::class) class ConversationDatabaseTest { private lateinit var context: Context private lateinit var databaseName: String private lateinit var database: ConversationDatabase + private lateinit var storageCipher: InMemoryConversationStorageCipher @Before fun setUp() { context = ApplicationProvider.getApplicationContext() databaseName = "conversation-test-${UUID.randomUUID()}.db" - database = ConversationDatabase(context, databaseName) + storageCipher = InMemoryConversationStorageCipher() + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) } @After @@ -71,7 +78,7 @@ class ConversationDatabaseTest { ) database.close() - database = ConversationDatabase(context, databaseName) + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) val restored = database.loadSnapshot() val restoredMessage = restored.chats.getValue("contact_alice").single() @@ -102,7 +109,7 @@ class ConversationDatabaseTest { targetConversationID = "contact_alice", aliases = setOf("mesh-alias", "nostr_alias", "contact_alice") ) - database.upsertMessage( + val duplicate = database.upsertMessage( "contact_alice", setOf("mesh-alias", "nostr_alias"), "alice", @@ -111,6 +118,7 @@ class ConversationDatabaseTest { ) val restored = database.loadSnapshot() + assertFalse(duplicate.inserted) assertEquals(setOf("contact_alice"), restored.chats.keys) assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id }) } @@ -132,7 +140,7 @@ class ConversationDatabaseTest { assertTrue(database.loadSnapshot().chats.isEmpty()) database.close() - database = ConversationDatabase(context, databaseName) + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) database.upsertMessage( "contact_alice", setOf("mesh-alias", "contact_alice"), @@ -188,7 +196,8 @@ class ConversationDatabaseTest { databaseName = databaseName, maxMessagesPerConversation = 10, maxMessagesTotal = 3, - maxPayloadBytes = Long.MAX_VALUE + maxPayloadBytes = Long.MAX_VALUE, + storageCipher = storageCipher ) repeat(4) { index -> database.upsertMessage( @@ -208,6 +217,324 @@ class ConversationDatabaseTest { assertTrue(snapshot.deletedMessageIDs.contains("single-0")) } + @Test + fun `initial snapshot decrypts only latest row while preserving exact unread count`() { + repeat(3) { index -> + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice"), + displayName = "alice", + message = message("summary-$index", "alice", index.toLong()), + isRead = index == 2 + ) + } + + val initial = database.loadInitialSnapshot() + + assertEquals(listOf("summary-2"), initial.chats.getValue("contact_alice").map { it.id }) + assertEquals(2, initial.unreadCounts.getValue("contact_alice")) + assertEquals(setOf("summary-2"), initial.readMessageIDs) + assertEquals(3, database.loadConversation("contact_alice").arrivalOrder.size) + } + + @Test + fun `sensitive message and display fields are encrypted and legacy columns are scrubbed`() { + val secret = "unique-secret-${UUID.randomUUID()}" + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice"), + displayName = "display-$secret", + message = BitchatMessage( + id = "encrypted-row", + sender = "sender-$secret", + content = "content-$secret", + timestamp = Date(42L), + isPrivate = true, + recipientNickname = "recipient-$secret" + ), + isRead = false + ) + + database.readableDatabase.query( + "private_messages", + arrayOf("sender", "content", "recipient_nickname", "payload_ciphertext"), + "message_id = ?", + arrayOf("encrypted-row"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("", cursor.getString(0)) + assertEquals("", cursor.getString(1)) + assertNull(cursor.getString(2)) + val envelope = cursor.getBlob(3) + assertFalse(envelope.toString(Charsets.ISO_8859_1).contains(secret)) + val plaintext = storageCipher.decrypt( + envelope, + "message:encrypted-row".toByteArray() + ).toString(Charsets.UTF_8) + assertTrue(plaintext.contains(secret)) + } + database.readableDatabase.query( + "conversations", + arrayOf("display_name", "display_name_ciphertext"), + "conversation_id = ?", + arrayOf("contact_alice"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertNull(cursor.getString(0)) + assertNotNull(cursor.getBlob(1)) + } + } + + @Test + fun `media byte retention reports only orphaned app-owned attachments`() { + database.close() + context.deleteDatabase(databaseName) + database = ConversationDatabase( + context = context, + databaseName = databaseName, + maxMessagesPerConversation = 10, + maxMessagesTotal = 10, + maxPayloadBytes = Long.MAX_VALUE, + maxMediaBytes = 4, + storageCipher = storageCipher + ) + val first = File(context.filesDir, "conversation-first-${UUID.randomUUID()}.bin") + .apply { writeBytes(byteArrayOf(1, 2, 3, 4)) } + val second = File(context.filesDir, "conversation-second-${UUID.randomUUID()}.bin") + .apply { writeBytes(byteArrayOf(5, 6, 7, 8)) } + try { + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("media-1", "alice", 1L).copy( + type = BitchatMessageType.File, + content = first.absolutePath + ), + true + ) + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("media-2", "alice", 2L).copy( + type = BitchatMessageType.File, + content = second.absolutePath + ), + true + ) + + val orphaned = database.pruneToRetentionLimits() + + assertEquals(setOf(first.canonicalPath), orphaned) + assertEquals(listOf("media-2"), database.loadSnapshot().arrivalOrder) + } finally { + first.delete() + second.delete() + } + } + + @Test + fun `shared attachment path is counted once and retained while referenced`() { + database.close() + context.deleteDatabase(databaseName) + database = ConversationDatabase( + context = context, + databaseName = databaseName, + maxMessagesPerConversation = 10, + maxMessagesTotal = 10, + maxPayloadBytes = Long.MAX_VALUE, + maxMediaBytes = 4, + storageCipher = storageCipher + ) + val shared = File(context.filesDir, "conversation-shared-${UUID.randomUUID()}.bin") + .apply { writeBytes(byteArrayOf(1, 2, 3, 4)) } + try { + repeat(2) { index -> + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("shared-$index", "alice", index.toLong()).copy( + type = BitchatMessageType.File, + content = shared.absolutePath + ), + true + ) + } + + assertTrue(database.pruneToRetentionLimits().isEmpty()) + assertEquals(2, database.loadSnapshot().arrivalOrder.size) + } finally { + shared.delete() + } + } + + @Test + fun `panic clear rotates the storage key before erasing rows`() { + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("before-panic", "alice", 1L), + true + ) + val envelope = database.readableDatabase.query( + "private_messages", + arrayOf("payload_ciphertext"), + "message_id = ?", + arrayOf("before-panic"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getBlob(0) + } + + database.clearAll() + + assertTrue(database.loadSnapshot().chats.isEmpty()) + assertThrows(Exception::class.java) { + storageCipher.decrypt(envelope, "message:before-panic".toByteArray()) + } + } + + @Test + fun `version one plaintext database migrates without losing history`() { + database.close() + context.deleteDatabase(databaseName) + createVersionOneDatabase( + conversationID = "legacy-contact", + messageID = "legacy-message", + sender = "legacy-alice", + content = "legacy-content" + ) + + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) + val restored = database.loadSnapshot() + + assertEquals("legacy-content", restored.chats.getValue("legacy-contact").single().content) + database.readableDatabase.query( + "private_messages", + arrayOf("sender", "content", "payload_ciphertext", "received_at"), + "message_id = ?", + arrayOf("legacy-message"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("", cursor.getString(0)) + assertEquals("", cursor.getString(1)) + assertNotNull(cursor.getBlob(2)) + assertEquals(123L, cursor.getLong(3)) + } + } + + private fun createVersionOneDatabase( + conversationID: String, + messageID: String, + sender: String, + content: String + ) { + context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null).use { db -> + 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 + ) + """.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 + ) + """.trimIndent() + ) + db.execSQL( + "CREATE TABLE deleted_private_messages (" + + "message_id TEXT PRIMARY KEY NOT NULL, deleted_at INTEGER NOT NULL)" + ) + db.insertOrThrow( + "conversations", + null, + ContentValues().apply { + put("conversation_id", conversationID) + put("display_name", sender) + put("created_at", 1L) + put("updated_at", 1L) + } + ) + db.insertOrThrow( + "conversation_aliases", + null, + ContentValues().apply { + put("alias", conversationID) + put("conversation_id", conversationID) + } + ) + db.insertOrThrow( + "private_messages", + null, + ContentValues().apply { + put("message_id", messageID) + put("conversation_id", conversationID) + put("sender", sender) + put("content", content) + put("message_type", BitchatMessageType.Message.ordinal) + put("sent_at", 123L) + put("is_relay", 0) + put("is_private", 1) + put("is_encrypted", 0) + put("delivery_type", 2) + put("is_read", 0) + } + ) + db.version = 1 + } + } + private fun message(id: String, sender: String, timestamp: Long) = BitchatMessage( id = id, sender = sender, diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt new file mode 100644 index 00000000..1ea81c45 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt @@ -0,0 +1,75 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.identity.SecureIdentityStateManager +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +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.UUID + +@RunWith(RobolectricTestRunner::class) +class ConversationListPreferencesTest { + private lateinit var context: Context + private lateinit var preferencesName: String + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + preferencesName = "conversation-list-${UUID.randomUUID()}" + } + + @Test + fun `pin mute and draft persist while conversation removal clears all three`() { + val first = preferences() + first.togglePinned("CONTACT_ALICE") + first.toggleMuted("contact_alice") + first.setDraft("contact_alice", "unfinished reply") + + val restored = preferences() + assertTrue(restored.isPinned("contact_alice")) + assertTrue(restored.isMuted("CONTACT_ALICE")) + assertTrue(restored.draftFor("contact_alice") == "unfinished reply") + + restored.removeConversation("contact_alice") + val afterDelete = preferences() + assertFalse(afterDelete.isPinned("contact_alice")) + assertFalse(afterDelete.isMuted("contact_alice")) + assertNull(afterDelete.draftFor("contact_alice")) + } + + @Test + fun `draft persistence is globally bounded and panic clear is durable`() { + val preferences = preferences() + repeat(60) { index -> + preferences.setDraft("peer-$index", "x".repeat(3_000)) + } + + assertTrue(preferences.drafts.value.size <= 50) + assertTrue(preferences.drafts.value.values.sumOf(String::length) <= 128_000) + assertNull(preferences.draftFor("peer-0")) + assertTrue(preferences.draftFor("peer-59")?.isNotEmpty() == true) + + preferences.togglePinned("peer-59") + preferences.toggleMuted("peer-59") + preferences.clearAll() + + val afterPanic = preferences() + assertTrue(afterPanic.pinned.value.isEmpty()) + assertTrue(afterPanic.muted.value.isEmpty()) + assertTrue(afterPanic.drafts.value.isEmpty()) + } + + private fun preferences(): ConversationListPreferences { + val sharedPreferences = + context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + return ConversationListPreferences( + stateManager = SecureIdentityStateManager(sharedPreferences, testOnly = true), + testOnly = true + ) + } +} 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 c42eb9aa..0d5947c9 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt @@ -21,6 +21,7 @@ import java.util.concurrent.atomic.AtomicReference class ConversationRepositoryTest { private lateinit var context: Context private lateinit var databaseName: String + private lateinit var repository: ConversationRepository private val executor = Executors.newSingleThreadExecutor() private val dispatcher = executor.asCoroutineDispatcher() @@ -32,16 +33,18 @@ class ConversationRepositoryTest { @After fun tearDown() { + if (::repository.isInitialized) repository.closeForTest() dispatcher.close() context.deleteDatabase(databaseName) } @Test fun `reload restores persisted history after initial process restore`() { - val repository = ConversationRepository( + repository = ConversationRepository( context = context, dispatcher = dispatcher, - databaseName = databaseName + databaseName = databaseName, + storageCipher = InMemoryConversationStorageCipher() ) val message = BitchatMessage( id = "persisted-message", @@ -78,10 +81,11 @@ class ConversationRepositoryTest { @Test fun `panic clear drains queued writes and leaves database empty`() { - val repository = ConversationRepository( + repository = ConversationRepository( context = context, dispatcher = dispatcher, - databaseName = databaseName + databaseName = databaseName, + storageCipher = InMemoryConversationStorageCipher() ) repository.upsertMessage( conversationID = "peer-alice", diff --git a/app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt b/app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt new file mode 100644 index 00000000..ee93be23 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.services + +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal class InMemoryConversationStorageCipher : ConversationStorageCipher { + private var key: SecretKey = generateKey() + + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(associatedData) + return byteArrayOf(1) + cipher.iv + cipher.doFinal(plaintext) + } + + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray { + require(envelope.size > 13 && envelope[0] == 1.toByte()) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(128, envelope.copyOfRange(1, 13)) + ) + cipher.updateAAD(associatedData) + return cipher.doFinal(envelope.copyOfRange(13, envelope.size)) + } + + override fun destroyKey() { + key = generateKey() + } + + private fun generateKey(): SecretKey = + KeyGenerator.getInstance("AES").apply { + init(256, SecureRandom()) + }.generateKey() +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt index ec471580..f94a498e 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt @@ -1,17 +1,46 @@ 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.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope 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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import java.util.Date +import java.util.UUID +@RunWith(RobolectricTestRunner::class) class IncomingMessageAdmissionTest { + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var repository: ConversationRepository + private val repositoriesToClose = mutableListOf() + @Before fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "admission-test-${UUID.randomUUID()}.db" + repository = ConversationRepository( + context = context, + dispatcher = Dispatchers.Unconfined, + databaseName = databaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + repositoriesToClose += repository + AppStateStore.setConversationRepositoryForTest(repository) AppStateStore.resumePrivateConversationsAfterPanic() AppStateStore.clear() } @@ -20,6 +49,10 @@ class IncomingMessageAdmissionTest { fun tearDown() { AppStateStore.resumePrivateConversationsAfterPanic() AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(null) + repositoriesToClose.forEach(ConversationRepository::closeForTest) + context.deleteDatabase(databaseName) + context.deleteDatabase("failing-$databaseName") } @Test @@ -42,6 +75,30 @@ class IncomingMessageAdmissionTest { assertFalse(IncomingMessageAdmission.admitToAppState(message)) } + @Test + fun `older retained replay is rejected after summary-only restart`() { + val older = privateMessage(id = "older-retained") + val latest = privateMessage(id = "latest-summary").copy(timestamp = Date(2L)) + assertTrue(IncomingMessageAdmission.admitToAppState(older)) + assertTrue(IncomingMessageAdmission.admitToAppState(latest)) + + AppStateStore.clear() + repository.reload { snapshot -> + AppStateStore.restorePrivateConversations(snapshot) + } + runBlocking { repository.awaitPendingWrites() } + assertEquals( + listOf(latest.id), + AppStateStore.privateMessages.value.getValue("peer-a").map { it.id } + ) + + assertFalse(IncomingMessageAdmission.admitToAppState(older)) + assertEquals( + listOf(latest.id), + AppStateStore.privateMessages.value.getValue("peer-a").map { it.id } + ) + } + @Test fun `public and channel messages preserve best effort admission`() { val public = BitchatMessage( @@ -57,6 +114,88 @@ class IncomingMessageAdmissionTest { assertTrue(AppStateStore.publicMessages.value.contains(public)) } + @Test + fun `outgoing callback runs only after local echo survives process state clear`() { + val state = ChatState(TestScope()) + state.setNickname("me") + val manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(context), + noiseSessionDelegate = testNoiseDelegate() + ) + var callbackInvoked = false + + val sent = runBlocking { + manager.sendPrivateMessageDurably( + content = "durable outgoing", + peerID = "peer-a", + recipientNickname = "alice", + senderNickname = "me", + myPeerID = "self" + ) { _, _, _, _ -> + callbackInvoked = true + } + } + + assertTrue(sent) + assertTrue(callbackInvoked) + AppStateStore.clear() + repository.reload { snapshot -> + assertEquals( + "durable outgoing", + snapshot.chats.getValue("peer-a").single().content + ) + } + runBlocking { repository.awaitPendingWrites() } + } + + @Test + fun `outgoing callback is suppressed when durable storage fails`() { + val failingRepository = ConversationRepository( + context = context, + dispatcher = Dispatchers.Unconfined, + databaseName = "failing-$databaseName", + storageCipher = object : ConversationStorageCipher { + override fun encrypt( + plaintext: ByteArray, + associatedData: ByteArray + ): ByteArray = error("storage unavailable") + + override fun decrypt( + envelope: ByteArray, + associatedData: ByteArray + ): ByteArray = error("storage unavailable") + + override fun destroyKey() = Unit + } + ) + repositoriesToClose += failingRepository + AppStateStore.setConversationRepositoryForTest(failingRepository) + val state = ChatState(TestScope()) + val manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(context), + noiseSessionDelegate = testNoiseDelegate() + ) + var callbackInvoked = false + + val sent = runBlocking { + manager.sendPrivateMessageDurably( + "lost", + "peer-a", + "alice", + "me", + "self" + ) { _, _, _, _ -> callbackInvoked = true } + } + + assertFalse(sent) + assertFalse(callbackInvoked) + assertTrue(state.getPrivateChatsValue().isEmpty()) + } + private fun privateMessage(id: String) = BitchatMessage( id = id, sender = "alice", @@ -65,4 +204,10 @@ class IncomingMessageAdmissionTest { isPrivate = true, senderPeerID = "peer-a" ) + + private fun testNoiseDelegate() = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String) = false + override fun initiateHandshake(peerID: String) = Unit + override fun getMyPeerID() = "self" + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt index 4b0ad570..0dc7aa25 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt @@ -141,6 +141,47 @@ class ConversationSummaryTest { assertEquals(BitchatMessageType.File, conversation.latestMessageType) } + @Test + fun `case variants cannot create duplicate conversations or misclassify outgoing messages`() { + val outgoing = incoming( + id = "outgoing", + sender = "Me", + timestamp = 200L, + content = "sent by me" + ) + + val conversations = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf( + "CONTACT_ALICE" to listOf(outgoing), + "contact_alice" to listOf(outgoing) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ) + + assertEquals(1, conversations.size) + assertEquals(setOf("contact_alice"), conversations.single().identityAliases) + assertEquals(true, conversations.single().latestMessageIsOutgoing) + } + + @Test + fun `summary-only startup uses persisted unread count`() { + val latest = incoming("latest", "alice", 300L) + + val conversation = buildConversationSummaries( + unreadConversationIDs = setOf("contact_alice"), + privateChats = mapOf("contact_alice" to listOf(latest)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { false }, + persistedUnreadCounts = mapOf("contact_alice" to 47) + ).single() + + assertEquals(47, conversation.unreadCount) + } + private fun incoming( id: String, sender: String, diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt index 6d6b1b1f..51204f30 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -6,7 +6,11 @@ import com.bitchat.android.mesh.PreparedPrivateMediaTransfer import com.bitchat.android.mesh.PrivateMediaPreparation import com.bitchat.android.mesh.PrivateMediaWireMode import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.services.ConversationRepository +import com.bitchat.android.services.InMemoryConversationStorageCipher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -32,6 +36,7 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +import java.util.UUID @RunWith(RobolectricTestRunner::class) class MediaSendingManagerMigrationTest { @@ -40,11 +45,22 @@ class MediaSendingManagerMigrationTest { private lateinit var mesh: MeshService private lateinit var manager: MediaSendingManager private lateinit var file: File + private lateinit var conversationRepository: ConversationRepository + private lateinit var conversationDatabaseName: String @Before fun setup() { state = ChatState(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) state.setNickname("me") + conversationDatabaseName = "media-send-${UUID.randomUUID()}.db" + conversationRepository = ConversationRepository( + context = org.robolectric.RuntimeEnvironment.getApplication(), + dispatcher = Dispatchers.Unconfined, + databaseName = conversationDatabaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(conversationRepository) mesh = mock() whenever(mesh.myPeerID).thenReturn("0011223344556677") whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "old peer")) @@ -63,6 +79,11 @@ class MediaSendingManagerMigrationTest { @After fun tearDown() { + AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(null) + conversationRepository.closeForTest() + org.robolectric.RuntimeEnvironment.getApplication() + .deleteDatabase(conversationDatabaseName) file.delete() } @@ -362,7 +383,7 @@ class MediaSendingManagerMigrationTest { } @Test - fun `failed prepared commit rolls back the local file echo`() { + fun `failed prepared commit keeps a retryable failed local file echo`() { whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) .thenAnswer { invocation -> PrivateMediaPreparation.Ready( @@ -378,9 +399,13 @@ class MediaSendingManagerMigrationTest { manager.sendImageNote(peerID, null, file.absolutePath) val messages = state.privateChats.value[peerID].orEmpty() - assertEquals(1, messages.size) - assertTrue(messages.single().content.contains("could not be committed")) - assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + assertEquals(2, messages.size) + val fileEcho = messages.single { it.type == BitchatMessageType.Image } + assertTrue(fileEcho.deliveryStatus is DeliveryStatus.Failed) + assertTrue( + messages.single { it.sender == "system" } + .content.contains("could not be committed") + ) } @Test