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 c7971eb8..503f26a6 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -99,6 +99,7 @@ object AppStateStore { synchronized(this) { if (seenMessageIds.contains(msg.id)) return seenMessageIds.add(msg.id) + PrivateMessageArrivalOrder.record(msg.id) val conversationID = ContactDirectory.canonicalConversationId(peerID) val map = _privateMessages.value.toMutableMap() val list = (map[conversationID] ?: emptyList()) + msg @@ -207,6 +208,7 @@ object AppStateStore { synchronized(this) { seenMessageIds.clear() seenPublicMessageKeys.clear() + PrivateMessageArrivalOrder.clear() peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() 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 eeec28e7..28cb695c 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -103,9 +103,9 @@ object ContactDirectory { return merged.mapValues { (_, messages) -> // A private message's timestamp comes from the sender and is not a reliable ordering - // signal when peers' clocks differ. The lists are populated in receive order, so keep - // their first occurrence in that same order while canonicalizing aliases. - messages.distinctBy { it.id } + // signal when peers' clocks differ. Use the local receipt sequence so interleaved + // alias lists can be merged back into their global arrival order. + PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) } } diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt new file mode 100644 index 00000000..a5c92453 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage + +/** + * Process-local receipt sequence for private messages. + * + * Peer-provided timestamps cannot safely order a conversation because clocks can differ. This + * registry follows the lifetime of [AppStateStore] and lets alias merges reconstruct the order in + * which messages entered the app, even when they were initially stored under different keys. + */ +internal object PrivateMessageArrivalOrder { + private val sequenceByMessageID = mutableMapOf() + private var nextSequence = 0L + + fun record(messageID: String) { + synchronized(this) { + if (messageID !in sequenceByMessageID) { + sequenceByMessageID[messageID] = nextSequence++ + } + } + } + + fun order(messages: List): List { + synchronized(this) { + if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) { + return messages + } + return messages.sortedBy { sequenceByMessageID.getValue(it.id) } + } + } + + fun clear() { + synchronized(this) { + sequenceByMessageID.clear() + nextSequence = 0L + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 0c7b64b2..41a610f4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.PrivateMessageArrivalOrder import java.util.* import java.util.Collections @@ -110,6 +111,8 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages + // Record the local arrival sequence before canonicalizing UI aliases. + PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) // Reflect into process-wide store try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } @@ -132,6 +135,8 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages + // Record the local arrival sequence before canonicalizing UI aliases. + PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) // Reflect into process-wide store try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt index 6286419f..ce3eedbd 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt @@ -29,27 +29,34 @@ class ConversationAliasResolverTest { } @Test - fun `explicit alias merge keeps arrival order when peer clocks differ`() { + fun `explicit alias merge restores global order for interleaved arrivals`() { val firstArrival = BitchatMessage( - id = "first-arrival", + id = "alias-a-first", sender = "alice", - content = "sent from a clock that is ahead", - timestamp = Date(3) + content = "first arrival through alias A", + timestamp = Date(300) ) val secondArrival = BitchatMessage( - id = "second-arrival", + id = "alias-b-first", sender = "alice", - content = "sent from a clock that is behind", - timestamp = Date(1) + content = "second arrival through alias B", + timestamp = Date(100) ) - state.setPrivateChats( - linkedMapOf( - "target-peer" to listOf(firstArrival), - "source-alias" to listOf(secondArrival) - ) + val thirdArrival = BitchatMessage( + id = "alias-a-second", + sender = "alice", + content = "third arrival through alias A", + timestamp = Date(200) ) AppStateStore.addPrivateMessage("target-peer", firstArrival) AppStateStore.addPrivateMessage("source-alias", secondArrival) + AppStateStore.addPrivateMessage("target-peer", thirdArrival) + state.setPrivateChats( + linkedMapOf( + "target-peer" to listOf(firstArrival, thirdArrival), + "source-alias" to listOf(secondArrival) + ) + ) ConversationAliasResolver.unifyChatsIntoPeer( state = state, @@ -58,11 +65,11 @@ class ConversationAliasResolverTest { ) assertEquals( - listOf(firstArrival, secondArrival), + listOf(firstArrival, secondArrival, thirdArrival), state.getPrivateChatsValue()["target-peer"] ) assertEquals( - listOf(firstArrival, secondArrival), + listOf(firstArrival, secondArrival, thirdArrival), AppStateStore.privateMessages.value["target-peer"] ) }