mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Fix live conversation identity state updates
This commit is contained in:
parent
a105d884cb
commit
172d086c23
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()}"
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user