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 b3ffc429..28cb695c 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -102,9 +102,10 @@ object ContactDirectory { } return merged.mapValues { (_, messages) -> - messages - .distinctBy { it.id } - .sortedWith(compareBy { it.timestamp.time }.thenBy { it.id }) + // A private message's timestamp comes from the sender and is not a reliable ordering + // 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/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index e3c2c61d..96fa15b8 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -65,7 +65,6 @@ object ConversationAliasResolver { if (didMerge) { currentChats[targetConversationID] = targetList .distinctBy { it.id } - .sortedBy { it.timestamp.time } state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentChats)) // Move unread flags 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/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index a70d4fa9..afce826f 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -128,16 +128,29 @@ class AppStateStoreTest { } @Test - fun `canonicalized private chat history is chronological after alias merge`() { + fun `canonicalized private chat history keeps arrival order when peer clocks differ`() { val noiseKeyHex = "01".repeat(32) val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 1 }) - val later = BitchatMessage(id = "later", sender = "alice", content = "later", timestamp = Date(3)) - val earlier = BitchatMessage(id = "earlier", sender = "alice", content = "earlier", timestamp = Date(1)) + val firstArrival = BitchatMessage( + id = "first-arrival", + sender = "alice", + content = "sent from a clock that is ahead", + timestamp = Date(3) + ) + val secondArrival = BitchatMessage( + id = "second-arrival", + sender = "alice", + content = "sent from a clock that is behind", + timestamp = Date(1) + ) - AppStateStore.addPrivateMessage(contactID, later) - AppStateStore.addPrivateMessage(noiseKeyHex, earlier) + AppStateStore.addPrivateMessage(contactID, firstArrival) + AppStateStore.addPrivateMessage(noiseKeyHex, secondArrival) - assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID]) + assertEquals( + listOf(firstArrival, secondArrival), + AppStateStore.privateMessages.value[contactID] + ) } @Test diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt new file mode 100644 index 00000000..ce3eedbd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt @@ -0,0 +1,76 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.ChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class ConversationAliasResolverTest { + private lateinit var scope: CoroutineScope + private lateinit var state: ChatState + + @Before + fun setUp() { + AppStateStore.clear() + scope = CoroutineScope(SupervisorJob()) + state = ChatState(scope) + } + + @After + fun tearDown() { + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `explicit alias merge restores global order for interleaved arrivals`() { + val firstArrival = BitchatMessage( + id = "alias-a-first", + sender = "alice", + content = "first arrival through alias A", + timestamp = Date(300) + ) + val secondArrival = BitchatMessage( + id = "alias-b-first", + sender = "alice", + content = "second arrival through alias B", + timestamp = Date(100) + ) + 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, + targetPeerID = "target-peer", + keysToMerge = listOf("source-alias") + ) + + assertEquals( + listOf(firstArrival, secondArrival, thirdArrival), + state.getPrivateChatsValue()["target-peer"] + ) + assertEquals( + listOf(firstArrival, secondArrival, thirdArrival), + AppStateStore.privateMessages.value["target-peer"] + ) + } +}