diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index 040acc8f..60801bb8 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -167,6 +167,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte if (ContactIdentityResolver.isMeshPeerId(pid)) { peerIdIndex[pid] = normalizedNpub savePeerIdIndex() + notifyChanged(pid) Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") } else { Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") 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 056e1524..fc4c439c 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -38,6 +38,10 @@ object AppStateStore { private val _unreadPrivateMessageCounts = MutableStateFlow>(emptyMap()) val unreadPrivateMessageCounts: StateFlow> = _unreadPrivateMessageCounts.asStateFlow() + private val _privateConversationDisplayNames = + MutableStateFlow>(emptyMap()) + val privateConversationDisplayNames: StateFlow> = + _privateConversationDisplayNames.asStateFlow() @Volatile private var conversationRepository: ConversationRepository? = null @@ -285,6 +289,11 @@ object AppStateStore { ?: msg.sender.takeUnless { it.isBlank() || it == "system" || it == _nickname.value } + displayName + ?.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } + ?.let { updateConversationDisplayNameLocked(conversationID, it) } if (persistAsynchronously) { conversationRepository?.upsertMessage( conversationID = conversationID, @@ -426,16 +435,48 @@ object AppStateStore { } else { map[targetConversationID] = targetList } - _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + _privateMessages.value = map } + canonicalizePrivateConversationStateLocked() } } fun canonicalizePrivateChats() { synchronized(this) { - val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value) - if (canonical != _privateMessages.value) { - _privateMessages.value = canonical + canonicalizePrivateConversationStateLocked() + } + } + + /** + * Applies current peer announcements to retained conversations and persists the latest name + * independently of message history. A nickname change must not require another message to + * survive process death. + */ + fun updatePrivateConversationDisplayNames(peerNicknames: Map) { + if (peerNicknames.isEmpty()) return + synchronized(this) { + if (privateConversationWritesSuspended || _privateMessages.value.isEmpty()) return + canonicalizePrivateConversationStateLocked() + val conversationIDs = _privateMessages.value.keys + .mapTo(mutableSetOf()) { it.lowercase() } + peerNicknames.forEach { (peerID, nickname) -> + val usableName = nickname.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } ?: return@forEach + val canonicalID = ContactDirectory.canonicalConversationId(peerID) + if (canonicalID.lowercase() !in conversationIDs) { + return@forEach + } + updateConversationDisplayNameLocked(canonicalID, usableName) + val aliases = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.aliasesForConversation(canonicalID) + }.getOrDefault(setOf(peerID, canonicalID)) + conversationRepository?.updateConversationIdentity( + conversationID = canonicalID, + aliases = aliases, + displayName = usableName + ) } } } @@ -444,6 +485,7 @@ object AppStateStore { synchronized(this) { if (privateConversationWritesSuspended) return if (messageID in _readPrivateMessageIDs.value) return + canonicalizePrivateConversationStateLocked() _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID val conversationID = _privateMessages.value.entries .firstOrNull { (_, messages) -> messages.any { it.id == messageID } } @@ -483,7 +525,10 @@ object AppStateStore { if (isRead) { _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageIDs _unreadPrivateMessageCounts.value = - _unreadPrivateMessageCounts.value - canonicalID + _unreadPrivateMessageCounts.value.filterKeys { key -> + !ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } } else { result.affectedMessageID?.let { latestMessageID -> _readPrivateMessageIDs.value = @@ -520,6 +565,7 @@ object AppStateStore { val updated = _privateMessages.value.toMutableMap() matchingKeys.forEach(updated::remove) _privateMessages.value = updated + removeConversationDisplayNamesLocked(canonicalID) _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs _unreadPrivateMessageCounts.value = _unreadPrivateMessageCounts.value - matchingKeys - canonicalID @@ -560,8 +606,10 @@ object AppStateStore { } } _privateMessages.value = updated + removeConversationDisplayNamesLocked(deletion.conversationID) _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - deletion.messageIDs + canonicalizePrivateConversationStateLocked() val counts = _unreadPrivateMessageCounts.value.toMutableMap() val currentCount = counts[deletion.conversationID] ?: 0 val remainingUnread = (currentCount - deletion.unreadMessageCount).coerceAtLeast(0) @@ -584,11 +632,14 @@ object AppStateStore { deletion: DeletedPrivateConversation ): Boolean { val repository = conversationRepository ?: return false + val restoredDisplayName = + ContactDirectory.resolve(deletion.conversationID).displayName + ?: deletion.displayName if ( !repository.restoreConversationAndWait( conversationID = deletion.conversationID, aliases = deletion.aliases, - displayName = deletion.displayName, + displayName = restoredDisplayName, messages = deletion.messages, readMessageIDs = deletion.readMessageIDs ) @@ -605,6 +656,9 @@ object AppStateStore { persistAsynchronously = false ) } + restoredDisplayName?.let { + updateConversationDisplayNameLocked(deletion.conversationID, it) + } } return true } @@ -632,7 +686,13 @@ object AppStateStore { return DeletedPrivateConversation( conversationID = canonicalID, aliases = aliases, - displayName = ContactDirectory.resolve(canonicalID).displayName, + displayName = _privateConversationDisplayNames.value.entries + .firstOrNull { (key, _) -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + ?.value + ?: ContactDirectory.resolve(canonicalID).displayName, messages = messages, readMessageIDs = readIDs, unreadMessageCount = messages.count { message -> @@ -662,6 +722,15 @@ object AppStateStore { if (!changed) return conversationRepository?.deleteMessage(messageID) _privateMessages.value = updated + val retainedConversationIDs = updated.keys + .mapTo(mutableSetOf()) { + ContactDirectory.canonicalConversationId(it).lowercase() + } + _privateConversationDisplayNames.value = + _privateConversationDisplayNames.value.filterKeys { + ContactDirectory.canonicalConversationId(it).lowercase() in + retainedConversationIDs + } _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID } } @@ -677,6 +746,7 @@ object AppStateStore { _privateMessages.value = emptyMap() _readPrivateMessageIDs.value = emptySet() _unreadPrivateMessageCounts.value = emptyMap() + _privateConversationDisplayNames.value = emptyMap() _selectedPrivateChatPeer.value = null conversationRepository } @@ -717,6 +787,7 @@ object AppStateStore { _privateMessages.value = emptyMap() _readPrivateMessageIDs.value = emptySet() _unreadPrivateMessageCounts.value = emptyMap() + _privateConversationDisplayNames.value = emptyMap() _channelMessages.value = emptyMap() _nickname.value = "" _selectedPrivateChatPeer.value = null @@ -775,11 +846,79 @@ object AppStateStore { else unreadCounts.remove(conversationID) } _unreadPrivateMessageCounts.value = unreadCounts + _privateConversationDisplayNames.value = + snapshot.displayNames + _privateConversationDisplayNames.value _privateMessages.value = ContactDirectory.canonicalizePrivateChats( merged.mapValues { (_, messages) -> PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) } ) + canonicalizePrivateConversationStateLocked() + } + } + + private fun updateConversationDisplayNameLocked( + conversationID: String, + displayName: String + ) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val updated = _privateConversationDisplayNames.value + .filterKeys { key -> + !ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + .toMutableMap() + updated[canonicalID] = displayName + _privateConversationDisplayNames.value = updated + } + + private fun removeConversationDisplayNamesLocked(conversationID: String) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + _privateConversationDisplayNames.value = + _privateConversationDisplayNames.value.filterKeys { key -> + !ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + } + + /** + * Identity mappings can become richer after a Noise handshake or favorite/Nostr update. + * Keep every process-wide projection on the same canonical key so unread/read/delete updates + * cannot leave a stale alias behind. + */ + private fun canonicalizePrivateConversationStateLocked() { + val canonicalChats = ContactDirectory.canonicalizePrivateChats(_privateMessages.value) + if (canonicalChats != _privateMessages.value) { + _privateMessages.value = canonicalChats + } + + val canonicalUnreadCounts = linkedMapOf() + _unreadPrivateMessageCounts.value.forEach { (conversationID, count) -> + if (count <= 0) return@forEach + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + canonicalUnreadCounts[canonicalID] = + (canonicalUnreadCounts[canonicalID] ?: 0) + count + } + if (canonicalUnreadCounts != _unreadPrivateMessageCounts.value) { + _unreadPrivateMessageCounts.value = canonicalUnreadCounts + } + + val canonicalDisplayNames = linkedMapOf() + _privateConversationDisplayNames.value.forEach { (conversationID, displayName) -> + if (displayName.isBlank()) return@forEach + canonicalDisplayNames[ + ContactDirectory.canonicalConversationId(conversationID) + ] = displayName + } + if (canonicalDisplayNames != _privateConversationDisplayNames.value) { + _privateConversationDisplayNames.value = canonicalDisplayNames + } + + _selectedPrivateChatPeer.value?.let { selected -> + val canonicalSelected = ContactDirectory.canonicalConversationId(selected) + if (canonicalSelected != selected) { + _selectedPrivateChatPeer.value = canonicalSelected + } } } diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index 28cb695c..7c733958 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -81,9 +81,19 @@ object ContactDirectory { conversationID = conversationID, meshPeerID = liveMeshPeerID, noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey }, - nostrPubkey = favorite?.peerNostrPublicKey, - displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + nostrPubkey = favorite?.peerNostrPublicKey + ?: liveMeshPeerID?.let { + runCatching { + FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(it) + }.getOrNull() + }, + // A connected peer's current announcement is authoritative. Favorite and fingerprint + // records are offline fallbacks and can legitimately contain an older nickname. + displayName = liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + ?: favorite?.peerNickname?.takeIf { + it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) + } ?: contactFingerprint?.let { cachedFingerprintNickname(it) }, isMutualFavorite = favorite?.isMutual == true ) diff --git a/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt index c9ab4945..658fa9fc 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt @@ -15,16 +15,19 @@ import org.json.JSONObject * Keystore-backed preference store. Panic clearing the identity store also removes these values. */ internal class ConversationListPreferences private constructor( - private val stateManager: SecureIdentityStateManager + private val stateManager: SecureIdentityStateManager, + private val canonicalize: (String) -> String ) { private constructor(context: Context) : this( - SecureIdentityStateManager(context.applicationContext) + SecureIdentityStateManager(context.applicationContext), + ContactDirectory::canonicalConversationId ) internal constructor( stateManager: SecureIdentityStateManager, - testOnly: Boolean - ) : this(stateManager) { + testOnly: Boolean, + canonicalize: (String) -> String = ContactDirectory::canonicalConversationId + ) : this(stateManager, canonicalize) { require(testOnly) { "Injected conversation preferences are test-only" } } @@ -95,6 +98,33 @@ internal class ConversationListPreferences private constructor( saveDrafts(_drafts.value) } + /** + * Re-key list preferences when a transient mesh ID becomes a stable contact identity. + * Without this, pin, mute, and draft state appears to disappear after a Noise/favorite update. + */ + fun canonicalizeAliases() { + val canonicalPinned = _pinned.value.mapTo(linkedSetOf(), ::normalize) + val canonicalMuted = _muted.value.mapTo(linkedSetOf(), ::normalize) + val canonicalDrafts = linkedMapOf() + _drafts.value.forEach { (conversationID, draft) -> + canonicalDrafts[normalize(conversationID)] = draft + } + + if (canonicalPinned != _pinned.value) { + _pinned.value = canonicalPinned + saveSet(PINNED_KEY, canonicalPinned) + } + if (canonicalMuted != _muted.value) { + _muted.value = canonicalMuted + saveSet(MUTED_KEY, canonicalMuted) + } + if (canonicalDrafts != _drafts.value) { + val bounded = boundDrafts(canonicalDrafts) + _drafts.value = bounded + saveDrafts(bounded) + } + } + fun clearInMemory() { _pinned.value = emptySet() _muted.value = emptySet() @@ -147,7 +177,7 @@ internal class ConversationListPreferences private constructor( if (value in this) this - value else this + value private fun normalize(value: String): String = - ContactDirectory.canonicalConversationId(value).lowercase() + canonicalize(value).lowercase() private fun boundDrafts(values: Map): Map { val retained = LinkedHashMap(values) 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 a4b3765c..84420ec0 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -224,6 +224,20 @@ class ConversationRepository internal constructor( } } + fun updateConversationIdentity( + conversationID: String, + aliases: Set, + displayName: String + ) { + scope.launch { + try { + database.updateConversationIdentity(conversationID, aliases, displayName) + } catch (error: Exception) { + Log.e(TAG, "Unable to persist conversation identity: ${error.message}") + } + } + } + fun deleteConversation(conversationID: String, aliases: Set) { scope.launch { deleteConversationLocked(conversationID, aliases) @@ -344,6 +358,7 @@ internal data class PersistedConversationSnapshot( val readMessageIDs: Set, val arrivalOrder: List, val deletedMessageIDs: Set, + val displayNames: Map = emptyMap(), val unreadCounts: Map = emptyMap(), val receivedAtByMessageID: Map = emptyMap(), val arrivalSequenceByMessageID: Map = emptyMap() @@ -612,7 +627,8 @@ internal class ConversationDatabase( return loadMessages( selection = null, selectionArgs = null, - orderBy = "arrival_sequence ASC" + orderBy = "arrival_sequence ASC", + displayNameConversationID = null ) } @@ -631,7 +647,8 @@ internal class ConversationDatabase( ) """.trimIndent(), selectionArgs = null, - orderBy = "arrival_sequence ASC" + orderBy = "arrival_sequence ASC", + displayNameConversationID = null ) } @@ -640,14 +657,16 @@ internal class ConversationDatabase( return loadMessages( selection = "conversation_id = ? COLLATE NOCASE", selectionArgs = arrayOf(resolved), - orderBy = "arrival_sequence ASC" + orderBy = "arrival_sequence ASC", + displayNameConversationID = resolved ) } private fun loadMessages( selection: String?, selectionArgs: Array?, - orderBy: String + orderBy: String, + displayNameConversationID: String? ): PersistedConversationSnapshot { val chats = linkedMapOf>() val readIDs = linkedSetOf() @@ -678,12 +697,51 @@ internal class ConversationDatabase( readMessageIDs = readIDs, arrivalOrder = arrivalOrder, deletedMessageIDs = loadDeletedMessageIDs(), + displayNames = loadConversationDisplayNames(displayNameConversationID), unreadCounts = loadUnreadCounts(), receivedAtByMessageID = receivedAtByMessageID, arrivalSequenceByMessageID = arrivalSequenceByMessageID ) } + private fun loadConversationDisplayNames( + conversationID: String? + ): Map { + val selection = conversationID?.let { "conversation_id = ? COLLATE NOCASE" } + val selectionArgs = conversationID?.let { arrayOf(it) } + return readableDatabase.query( + "conversations", + arrayOf("conversation_id", "display_name", "display_name_ciphertext"), + selection, + selectionArgs, + null, + null, + null + ).use { cursor -> + buildMap { + while (cursor.moveToNext()) { + val storedConversationID = cursor.string("conversation_id") + val encryptedColumn = + cursor.getColumnIndexOrThrow("display_name_ciphertext") + val encryptedName = if (cursor.isNull(encryptedColumn)) { + null + } else { + cursor.getBlob(encryptedColumn) + } + val displayName = encryptedName?.let { + storageCipher.decrypt( + it, + conversationDisplayNameAad(storedConversationID) + ).toString(Charsets.UTF_8) + } ?: cursor.nullableString("display_name") + displayName + ?.takeIf(String::isNotBlank) + ?.let { put(storedConversationID, it) } + } + } + } + } + private fun loadDeletedMessageIDs(): Set { readableDatabase.query( "deleted_private_messages", @@ -907,6 +965,23 @@ internal class ConversationDatabase( } } + fun updateConversationIdentity( + conversationID: String, + aliases: Set, + displayName: String + ) { + if (conversationID.isBlank() || displayName.isBlank()) return + writableDatabase.inTransaction { + mergeAliasesLocked( + db = this, + targetConversationID = conversationID, + aliases = aliases + conversationID, + displayName = displayName, + now = System.currentTimeMillis() + ) + } + } + fun deleteConversation(conversationID: String, aliases: Set): Set = writableDatabase.inTransaction { val ids = linkedSetOf() @@ -1103,7 +1178,6 @@ internal class ConversationDatabase( displayName: String?, now: Long ) { - ensureConversationLocked(db, targetConversationID, displayName, now) val normalizedAliases = aliases .map(String::trim) .filter(String::isNotBlank) @@ -1117,6 +1191,12 @@ internal class ConversationDatabase( selectionValues = normalizedAliases ) ) + val effectiveDisplayName = displayName + ?: (listOf(targetConversationID) + sourceIDs + normalizedAliases) + .asSequence() + .mapNotNull { storedConversationDisplayNameLocked(db, it) } + .firstOrNull() + ensureConversationLocked(db, targetConversationID, effectiveDisplayName, now) sourceIDs .filterNot { it.equals(targetConversationID, ignoreCase = true) } @@ -1151,7 +1231,35 @@ internal class ConversationDatabase( SQLiteDatabase.CONFLICT_REPLACE ) } - updateConversationMetadataLocked(db, targetConversationID, displayName, now) + updateConversationMetadataLocked( + db, + targetConversationID, + effectiveDisplayName, + now + ) + } + + private fun storedConversationDisplayNameLocked( + db: SQLiteDatabase, + conversationID: String + ): String? = db.query( + "conversations", + arrayOf("conversation_id", "display_name", "display_name_ciphertext"), + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID), + null, + null, + null, + "1" + ).use { cursor -> + if (!cursor.moveToFirst()) return@use null + val storedConversationID = cursor.string("conversation_id") + cursor.blobOrNull("display_name_ciphertext")?.let { + storageCipher.decrypt( + it, + conversationDisplayNameAad(storedConversationID) + ).toString(Charsets.UTF_8) + } ?: cursor.nullableString("display_name") } private fun ensureConversationLocked( 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 da3738b8..59a126a6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -99,9 +99,11 @@ fun ChatScreen(viewModel: ChatViewModel) { var isScrolledUp by remember { mutableStateOf(false) } LaunchedEffect(selectedPrivatePeer) { - selectedPrivatePeer?.let { peerID -> - messageText = TextFieldValue(viewModel.conversationDraft(peerID)) - } + messageText = TextFieldValue( + selectedPrivatePeer + ?.let(viewModel::conversationDraft) + .orEmpty() + ) } // Show password dialog when needed 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 205b17d5..29b0f394 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.bitchat.android.favorites.FavoritesChangeListener import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.Job import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService @@ -39,6 +41,12 @@ import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString +private data class ConversationLiveIdentityState( + val connectedPeerIDs: List, + val peerNicknames: Map, + val persistedDisplayNames: Map +) + /** * Refactored ChatViewModel - Main coordinator for bitchat functionality * Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility @@ -207,20 +215,58 @@ class ChatViewModel( com.bitchat.android.services.AppStateStore.conversationStoreState private val conversationPresencePeers = MutableStateFlow>(emptyList()) private val conversationPresenceRemovalJobs = mutableMapOf() + private val conversationDirectoryRevision = MutableStateFlow(0L) + private var favoriteRelationshipListenerRegistered = false + private val favoriteRelationshipChangeListener = object : FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) { + refreshConversationDirectoryState() + } + + override fun onAllCleared() { + refreshConversationDirectoryState() + } + } + + private fun refreshConversationDirectoryState() { + viewModelScope.launch { + refreshPeerFavoritedUs() + conversationListPreferences.canonicalizeAliases() + conversationDirectoryRevision.update { it + 1L } + } + } + + private val conversationLiveIdentityState = combine( + conversationPresencePeers, + state.peerNicknames, + state.peerFingerprints, + conversationDirectoryRevision, + com.bitchat.android.services.AppStateStore.privateConversationDisplayNames + ) { connectedPeerIDs, peerNicknames, _, _, persistedDisplayNames -> + ConversationLiveIdentityState( + connectedPeerIDs = connectedPeerIDs, + peerNicknames = peerNicknames, + persistedDisplayNames = persistedDisplayNames + .mapKeys { (conversationID, _) -> conversationID.lowercase() } + ) + } private val baseConversations = combine( state.unreadPrivateMessages, state.privateChats, state.nickname, - conversationPresencePeers, + conversationLiveIdentityState, com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts - ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs, unreadCounts -> + ) { unreadConversationIDs, chats, currentNickname, liveIdentity, unreadCounts -> val seenStore = seenMessageStore - val connectedIdentitiesByPeer = connectedPeerIDs.associateWith { peerID -> - runCatching { - ContactDirectory.aliasesForConversation(peerID) + - ContactDirectory.canonicalConversationId(peerID) - }.getOrDefault(setOf(peerID)) - .mapTo(mutableSetOf()) { it.lowercase() } + val connectedPeerByIdentity = buildMap { + liveIdentity.connectedPeerIDs.forEach { peerID -> + val identities = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.canonicalConversationId(peerID) + }.getOrDefault(setOf(peerID)) + identities.forEach { identity -> + putIfAbsent(identity.lowercase(), peerID) + } + } } buildConversationSummaries( unreadConversationIDs = unreadConversationIDs, @@ -246,19 +292,31 @@ class ChatViewModel( ?.let(ContactIdentityResolver::nostrAliasForPubkey) ?.let(::add) }.mapTo(mutableSetOf()) { it.lowercase() } - val connectedPeerID = connectedIdentitiesByPeer.entries - .firstOrNull { (_, connectedAliases) -> - connectedAliases.any(aliases::contains) - } - ?.key + val connectedPeerID = aliases + .asSequence() + .mapNotNull(connectedPeerByIdentity::get) + .firstOrNull() + val persistedDisplayName = liveIdentity.persistedDisplayNames[ + summary.conversationID.lowercase() + ] ?: aliases + .asSequence() + .mapNotNull(liveIdentity.persistedDisplayNames::get) + .firstOrNull() summary.copy( - displayName = resolution.displayName - ?.takeUnless { - it.isBlank() || it.equals("Unknown", ignoreCase = true) - } - ?: summary.displayName, + displayName = resolveConversationDisplayName( + fallbackName = summary.displayName, + connectedPeerID = connectedPeerID, + peerNicknames = liveIdentity.peerNicknames, + resolvedContactName = resolution.displayName, + persistedDisplayName = persistedDisplayName + ), nostrPubkey = resolvedNostrPubkey, + transport = if (resolvedNostrPubkey != null) { + DirectMessageTransport.NOSTR + } else { + summary.transport + }, identityAliases = aliases, isConnected = connectedPeerID != null, connectedPeerID = connectedPeerID, @@ -344,6 +402,7 @@ class ChatViewModel( loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() + observeConversationDisplayNames() // Application startup performs the initial restore. Repeat it for every new UI owner // because a quick reopen can reuse a process whose in-memory state was cleared during // controlled shutdown. @@ -458,6 +517,24 @@ class ChatViewModel( } } + private fun observeConversationDisplayNames() { + viewModelScope.launch { + combine( + state.peerNicknames, + state.connectedPeers, + state.peerFingerprints + ) { peerNicknames, connectedPeers, _ -> + connectedPeers.mapNotNull { peerID -> + peerNicknames[peerID]?.let { peerID to it } + }.toMap() + }.collect { connectedNames -> + conversationListPreferences.canonicalizeAliases() + com.bitchat.android.services.AppStateStore + .updatePrivateConversationDisplayNames(connectedNames) + } + } + } + fun cancelMediaSend(messageId: String) { // Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state mediaSendingManager.cancelMediaSend(messageId) @@ -521,11 +598,9 @@ class ChatViewModel( refreshPeerFavoritedUs() try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener( - object : com.bitchat.android.favorites.FavoritesChangeListener { - override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs() - override fun onAllCleared() = refreshPeerFavoritedUs() - } + favoriteRelationshipChangeListener ) + favoriteRelationshipListenerRegistered = true } catch (_: Exception) { } // Load verified fingerprints from secure storage @@ -544,6 +619,14 @@ class ChatViewModel( } override fun onCleared() { + if (favoriteRelationshipListenerRegistered) { + runCatching { + FavoritesPersistenceService.shared.removeListener( + favoriteRelationshipChangeListener + ) + } + favoriteRelationshipListenerRegistered = false + } geohashViewModel.shutdownUiSubscriptions() com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) // Note: Mesh service lifecycle is now managed by MainActivity 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 5ec4b02f..2178d793 100644 --- a/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt +++ b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt @@ -75,8 +75,12 @@ internal fun buildConversationSummaries( val latest = messages.maxWithOrNull( compareBy(::activityOrder).thenBy { it.id } ) ?: return@mapNotNull null + fun isOutgoing(message: BitchatMessage): Boolean = + message.sender.lowercase() in currentUsers || + message.senderPeerID?.lowercase() in currentUsers + val incoming = messages.filterNot { - it.sender.lowercase() in currentUsers || it.sender == "system" + isOutgoing(it) || it.sender == "system" } val latestIncoming = incoming.maxWithOrNull( compareBy(::activityOrder).thenBy { it.id } @@ -85,7 +89,7 @@ internal fun buildConversationSummaries( val persistedUnreadCount = unreadCountsByCanonicalID[conversationID.lowercase()] ?: 0 val unreadCount = maxOf( persistedUnreadCount, - if (canonicalUnread) { + if (canonicalUnread && incoming.isNotEmpty()) { incoming.count { !isMessageRead(it) }.coerceAtLeast(1) } else { 0 @@ -115,7 +119,7 @@ internal fun buildConversationSummaries( latestActivityOrder = activityOrder(latest), latestMessageType = latest.type, latestMessagePreview = latest.conversationPreview(), - latestMessageIsOutgoing = latest.sender.lowercase() in currentUsers, + latestMessageIsOutgoing = isOutgoing(latest), latestDeliveryStatus = latest.deliveryStatus, transport = if (isNostrConversation) { DirectMessageTransport.NOSTR @@ -129,6 +133,31 @@ internal fun buildConversationSummaries( } } +internal fun resolveConversationDisplayName( + fallbackName: String, + connectedPeerID: String?, + peerNicknames: Map, + resolvedContactName: String?, + persistedDisplayName: String? +): String { + fun String?.usableName(): String? = this?.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } + + val liveName = connectedPeerID?.let { peerID -> + peerNicknames[peerID] + ?: peerNicknames.entries + .firstOrNull { (candidateID, _) -> + candidateID.equals(peerID, ignoreCase = true) + } + ?.value + } + return liveName.usableName() + ?: resolvedContactName.usableName() + ?: persistedDisplayName.usableName() + ?: fallbackName +} + internal fun sortConversationSummaries( conversations: List ): List = conversations.sortedWith( 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 44c32826..893abe0e 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -135,6 +135,60 @@ class AppStateStoreTest { assertEquals(listOf(message), AppStateStore.privateMessages.value[contactID]) } + @Test + fun `live nickname updates retained conversation metadata`() { + val message = BitchatMessage( + id = "rename-message", + sender = "Alice Old", + content = "hello", + timestamp = Date(1), + isPrivate = true + ) + AppStateStore.addPrivateMessage("peer-a", message) + + AppStateStore.updatePrivateConversationDisplayNames( + mapOf("peer-a" to "Alice New") + ) + + assertEquals( + "Alice New", + AppStateStore.privateConversationDisplayNames.value.getValue("peer-a") + ) + } + + @Test + fun `restored unread and nickname state canonicalize with message aliases`() { + val noiseKeyHex = "02".repeat(32) + val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey( + ByteArray(32) { 2 } + ) + val message = BitchatMessage( + id = "restored-alias-message", + sender = "Alice", + content = "hello", + timestamp = Date(1), + isPrivate = true + ) + + AppStateStore.restorePrivateConversations( + PersistedConversationSnapshot( + chats = mapOf(noiseKeyHex to listOf(message)), + readMessageIDs = emptySet(), + arrivalOrder = listOf(message.id), + deletedMessageIDs = emptySet(), + displayNames = mapOf(noiseKeyHex to "Alice Renamed"), + unreadCounts = mapOf(noiseKeyHex to 1) + ) + ) + + assertEquals(setOf(contactID), AppStateStore.privateMessages.value.keys) + assertEquals(mapOf(contactID to 1), AppStateStore.unreadPrivateMessageCounts.value) + assertEquals( + mapOf(contactID to "Alice Renamed"), + AppStateStore.privateConversationDisplayNames.value + ) + } + @Test fun `canonicalized private chat history keeps arrival order when peer clocks differ`() { val noiseKeyHex = "01".repeat(32) @@ -318,8 +372,12 @@ class AppStateStoreTest { val afterPanic = beforePanic.copy(id = "after-panic") assertTrue(AppStateStore.addPrivateMessage("peer-a", beforePanic)) + AppStateStore.updatePrivateConversationDisplayNames( + mapOf("peer-a" to "Alice") + ) assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() }) assertTrue(AppStateStore.privateMessages.value.isEmpty()) + assertTrue(AppStateStore.privateConversationDisplayNames.value.isEmpty()) assertFalse(AppStateStore.addPrivateMessage("peer-a", duringPanic)) AppStateStore.resumePrivateConversationsAfterPanic() 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 30ff5106..9a01ef37 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt @@ -96,6 +96,7 @@ class ConversationDatabaseTest { ) assertEquals(setOf(message.id), restored.readMessageIDs) assertEquals(listOf(message.id), restored.arrivalOrder) + assertEquals("alice", restored.displayNames.getValue("contact_alice")) } @Test @@ -112,7 +113,7 @@ class ConversationDatabaseTest { val duplicate = database.upsertMessage( "contact_alice", setOf("mesh-alias", "nostr_alias"), - "alice", + null, first, false ) @@ -121,6 +122,7 @@ class ConversationDatabaseTest { assertFalse(duplicate.inserted) assertEquals(setOf("contact_alice"), restored.chats.keys) assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id }) + assertEquals("alice", restored.displayNames.getValue("contact_alice")) } @Test @@ -237,6 +239,31 @@ class ConversationDatabaseTest { assertEquals(3, database.loadConversation("contact_alice").arrivalOrder.size) } + @Test + fun `nickname metadata updates without requiring a new message`() { + database.upsertMessage( + conversationID = "mesh-alias", + aliases = setOf("mesh-alias"), + displayName = "Alice Old", + message = message("rename-message", "Alice Old", 1L), + isRead = true + ) + + database.updateConversationIdentity( + conversationID = "contact_alice", + aliases = setOf("mesh-alias", "contact_alice"), + displayName = "Alice New" + ) + database.close() + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) + + val restored = database.loadInitialSnapshot() + + assertEquals(setOf("contact_alice"), restored.chats.keys) + assertEquals("Alice New", restored.displayNames.getValue("contact_alice")) + assertEquals("Alice Old", restored.chats.getValue("contact_alice").single().sender) + } + @Test fun `sensitive message and display fields are encrypted and legacy columns are scrubbed`() { val secret = "unique-secret-${UUID.randomUUID()}" diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt index 1ea81c45..48928ef4 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt @@ -3,6 +3,8 @@ package com.bitchat.android.services import android.content.Context import androidx.test.core.app.ApplicationProvider import com.bitchat.android.identity.SecureIdentityStateManager +import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -21,6 +23,12 @@ class ConversationListPreferencesTest { fun setUp() { context = ApplicationProvider.getApplicationContext() preferencesName = "conversation-list-${UUID.randomUUID()}" + ContactDirectory.initialize(context) { null } + } + + @After + fun tearDown() { + ContactDirectory.initialize(context) { null } } @Test @@ -64,12 +72,39 @@ class ConversationListPreferencesTest { assertTrue(afterPanic.drafts.value.isEmpty()) } - private fun preferences(): ConversationListPreferences { + @Test + fun `identity canonicalization preserves pin mute and draft state`() { + val peerID = "1122334455667788" + val contactID = "contact_alice" + val beforeIdentityResolution = preferences(canonicalize = { it }) + beforeIdentityResolution.togglePinned(peerID) + beforeIdentityResolution.toggleMuted(peerID) + beforeIdentityResolution.setDraft(peerID, "unfinished reply") + + val afterIdentityResolution = preferences( + canonicalize = { value -> + if (value.equals(peerID, ignoreCase = true)) contactID else value + } + ) + afterIdentityResolution.canonicalizeAliases() + + assertEquals(setOf(contactID), afterIdentityResolution.pinned.value) + assertEquals(setOf(contactID), afterIdentityResolution.muted.value) + assertEquals( + mapOf(contactID to "unfinished reply"), + afterIdentityResolution.drafts.value + ) + } + + private fun preferences( + canonicalize: (String) -> String = ContactDirectory::canonicalConversationId + ): ConversationListPreferences { val sharedPreferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) return ConversationListPreferences( stateManager = SecureIdentityStateManager(sharedPreferences, testOnly = true), - testOnly = true + testOnly = true, + canonicalize = canonicalize ) } } 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 0dc7aa25..2e6128a1 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt @@ -6,6 +6,7 @@ import com.bitchat.android.services.PrivateMessageArrivalOrder import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import java.util.Date @@ -166,6 +167,56 @@ class ConversationSummaryTest { assertEquals(true, conversations.single().latestMessageIsOutgoing) } + @Test + fun `outgoing messages remain outgoing after local nickname changes`() { + val outgoing = incoming( + id = "outgoing-before-rename", + sender = "old nickname", + timestamp = 200L, + content = "sent before rename" + ).copy(senderPeerID = "my-stable-peer-id") + + val conversation = buildConversationSummaries( + unreadConversationIDs = setOf("contact_alice"), + privateChats = mapOf("contact_alice" to listOf(outgoing)), + currentUserIdentifiers = setOf("new nickname", "my-stable-peer-id"), + canonicalize = { it }, + isMessageRead = { false }, + persistedUnreadCounts = emptyMap() + ).single() + + assertTrue(conversation.latestMessageIsOutgoing) + assertEquals(0, conversation.unreadCount) + } + + @Test + fun `connected nickname overrides cached conversation names`() { + assertEquals( + "Alice Renamed", + resolveConversationDisplayName( + fallbackName = "Alice From Message", + connectedPeerID = "peer-a", + peerNicknames = mapOf("peer-a" to "Alice Renamed"), + resolvedContactName = "Alice Favorite Snapshot", + persistedDisplayName = "Alice Persisted" + ) + ) + } + + @Test + fun `persisted nickname is used when no live or contact name remains`() { + assertEquals( + "Alice Persisted", + resolveConversationDisplayName( + fallbackName = "Alice From Message", + connectedPeerID = null, + peerNicknames = emptyMap(), + resolvedContactName = null, + persistedDisplayName = "Alice Persisted" + ) + ) + } + @Test fun `summary-only startup uses persisted unread count`() { val latest = incoming("latest", "alice", 300L)