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..eeec28e7 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. The lists are populated in receive order, so keep + // their first occurrence in that same order while canonicalizing aliases. + 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/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..6286419f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt @@ -0,0 +1,69 @@ +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 keeps arrival order when peer clocks differ`() { + 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) + ) + state.setPrivateChats( + linkedMapOf( + "target-peer" to listOf(firstArrival), + "source-alias" to listOf(secondArrival) + ) + ) + AppStateStore.addPrivateMessage("target-peer", firstArrival) + AppStateStore.addPrivateMessage("source-alias", secondArrival) + + ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = "target-peer", + keysToMerge = listOf("source-alias") + ) + + assertEquals( + listOf(firstArrival, secondArrival), + state.getPrivateChatsValue()["target-peer"] + ) + assertEquals( + listOf(firstArrival, secondArrival), + AppStateStore.privateMessages.value["target-peer"] + ) + } +}