Merge pull request #796 from permissionlesstech/codex/private-message-arrival-order

Fix private message ordering across clock skew
This commit is contained in:
callebtc 2026-07-27 23:50:05 +02:00 committed by GitHub
commit 15a580aeef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 145 additions and 10 deletions

View File

@ -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()

View File

@ -102,9 +102,10 @@ object ContactDirectory {
}
return merged.mapValues { (_, messages) ->
messages
.distinctBy { it.id }
.sortedWith(compareBy<BitchatMessage> { 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 })
}
}

View File

@ -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

View File

@ -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<String, Long>()
private var nextSequence = 0L
fun record(messageID: String) {
synchronized(this) {
if (messageID !in sequenceByMessageID) {
sequenceByMessageID[messageID] = nextSequence++
}
}
}
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
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
}
}
}

View File

@ -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) { }

View File

@ -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

View File

@ -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"]
)
}
}