From dfe4fba2128778580c6657bdcc22ab789784474e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:50:27 +0200 Subject: [PATCH 1/2] fix: preserve private message arrival order --- .../android/services/ContactDirectory.kt | 7 +- .../services/ConversationAliasResolver.kt | 1 - .../android/services/AppStateStoreTest.kt | 25 +++++-- .../services/ConversationAliasResolverTest.kt | 69 +++++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt 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"] + ) + } +} From 62926f88d5414307bde160e7f8265b628f587758 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:29:01 +0200 Subject: [PATCH 2/2] fix: preserve arrival order across aliases --- .../bitchat/android/services/AppStateStore.kt | 2 + .../android/services/ContactDirectory.kt | 6 +-- .../services/PrivateMessageArrivalOrder.kt | 39 +++++++++++++++++++ .../com/bitchat/android/ui/MessageManager.kt | 5 +++ .../services/ConversationAliasResolverTest.kt | 35 ++++++++++------- 5 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt 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"] ) }