Fix live conversation identity state updates

This commit is contained in:
callebtc 2026-07-29 14:23:25 +02:00
parent a105d884cb
commit 172d086c23
12 changed files with 625 additions and 52 deletions

View File

@ -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")

View File

@ -38,6 +38,10 @@ object AppStateStore {
private val _unreadPrivateMessageCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadPrivateMessageCounts: StateFlow<Map<String, Int>> =
_unreadPrivateMessageCounts.asStateFlow()
private val _privateConversationDisplayNames =
MutableStateFlow<Map<String, String>>(emptyMap())
val privateConversationDisplayNames: StateFlow<Map<String, String>> =
_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<String, String>) {
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<String, Int>()
_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<String, String>()
_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
}
}
}

View File

@ -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
)

View File

@ -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<String, String>()
_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<String, String>): Map<String, String> {
val retained = LinkedHashMap(values)

View File

@ -224,6 +224,20 @@ class ConversationRepository internal constructor(
}
}
fun updateConversationIdentity(
conversationID: String,
aliases: Set<String>,
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<String>) {
scope.launch {
deleteConversationLocked(conversationID, aliases)
@ -344,6 +358,7 @@ internal data class PersistedConversationSnapshot(
val readMessageIDs: Set<String>,
val arrivalOrder: List<String>,
val deletedMessageIDs: Set<String>,
val displayNames: Map<String, String> = emptyMap(),
val unreadCounts: Map<String, Int> = emptyMap(),
val receivedAtByMessageID: Map<String, Long> = emptyMap(),
val arrivalSequenceByMessageID: Map<String, Long> = 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<String>?,
orderBy: String
orderBy: String,
displayNameConversationID: String?
): PersistedConversationSnapshot {
val chats = linkedMapOf<String, MutableList<BitchatMessage>>()
val readIDs = linkedSetOf<String>()
@ -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<String, String> {
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<String> {
readableDatabase.query(
"deleted_private_messages",
@ -907,6 +965,23 @@ internal class ConversationDatabase(
}
}
fun updateConversationIdentity(
conversationID: String,
aliases: Set<String>,
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<String>): Set<String> =
writableDatabase.inTransaction {
val ids = linkedSetOf<String>()
@ -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(

View File

@ -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

View File

@ -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<String>,
val peerNicknames: Map<String, String>,
val persistedDisplayNames: Map<String, String>
)
/**
* 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<List<String>>(emptyList())
private val conversationPresenceRemovalJobs = mutableMapOf<String, Job>()
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

View File

@ -75,8 +75,12 @@ internal fun buildConversationSummaries(
val latest = messages.maxWithOrNull(
compareBy<BitchatMessage>(::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<BitchatMessage>(::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<String, String>,
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<ConversationSummary>
): List<ConversationSummary> = conversations.sortedWith(

View File

@ -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()

View File

@ -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()}"

View File

@ -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
)
}
}

View File

@ -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)