mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
fix: order mesh timeline by source packet timestamp
Incoming public and channel messages were appended in receive order, so when a peer store-forwards or gossip-syncs an older backlog on reconnect, hour-old messages landed at the bottom interleaved with current ones. BitchatMessage.timestamp already carries the source packet time, so insert each message at its timestamp position instead of appending. Ordering is a stable binary-search insertion (equal timestamps keep insertion order), applied in the AppStateStore public/channel add paths (the timeline's source of truth) and the matching MessageManager add paths. Fixes #525.
This commit is contained in:
parent
b7f0b33d3a
commit
9aedd06dfb
@ -91,7 +91,10 @@ object AppStateStore {
|
||||
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
seenPublicMessageKeys.add(publicKey)
|
||||
_publicMessages.value = _publicMessages.value + msg
|
||||
// Insert by source packet timestamp so a store-forwarded/gossip-synced backlog is
|
||||
// interleaved chronologically instead of appended at the bottom in receive order.
|
||||
_publicMessages.value = com.bitchat.android.util.MessageOrdering
|
||||
.withMessageInserted(_publicMessages.value, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,8 +147,8 @@ object AppStateStore {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
val map = _channelMessages.value.toMutableMap()
|
||||
val list = (map[channel] ?: emptyList()) + msg
|
||||
map[channel] = list
|
||||
map[channel] = com.bitchat.android.util.MessageOrdering
|
||||
.withMessageInserted(map[channel] ?: emptyList(), msg)
|
||||
_channelMessages.value = map
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,9 @@ class MessageManager(private val state: ChatState) {
|
||||
|
||||
fun addMessage(message: BitchatMessage) {
|
||||
val currentMessages = state.getMessagesValue().toMutableList()
|
||||
currentMessages.add(message)
|
||||
// Order by the source packet timestamp so a store-forwarded backlog lands in its
|
||||
// chronological slot instead of appending at the bottom in receive order.
|
||||
com.bitchat.android.util.MessageOrdering.insertByTimestamp(currentMessages, message)
|
||||
state.setMessages(currentMessages)
|
||||
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
|
||||
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
|
||||
@ -51,7 +53,7 @@ class MessageManager(private val state: ChatState) {
|
||||
}
|
||||
|
||||
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
|
||||
channelMessageList.add(message)
|
||||
com.bitchat.android.util.MessageOrdering.insertByTimestamp(channelMessageList, message)
|
||||
currentChannelMessages[channel] = channelMessageList
|
||||
state.setChannelMessages(currentChannelMessages)
|
||||
// Reflect into process-wide store
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package com.bitchat.android.util
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Ordering helpers for chat timelines.
|
||||
*
|
||||
* Incoming mesh messages can arrive out of order. When a peer store-forwards or gossip-syncs an
|
||||
* older backlog after reconnecting, hour-old messages show up after current ones if we simply
|
||||
* append in receive order. [BitchatMessage.timestamp] carries the source packet time, so inserting
|
||||
* each message at its timestamp position keeps the visible timeline chronological.
|
||||
*/
|
||||
object MessageOrdering {
|
||||
|
||||
/**
|
||||
* Insert [message] into [messages] so the list stays sorted by [BitchatMessage.timestamp].
|
||||
*
|
||||
* Assumes [messages] is already timestamp-ordered (the timelines it is used on are always built
|
||||
* through this path), so it can binary-search the insertion point and stay cheap on long lists.
|
||||
* Ordering is stable: a message whose timestamp equals existing ones is placed after them, so
|
||||
* equal timestamps keep insertion order.
|
||||
*/
|
||||
fun insertByTimestamp(messages: MutableList<BitchatMessage>, message: BitchatMessage) {
|
||||
val ts = message.timestamp.time
|
||||
var lo = 0
|
||||
var hi = messages.size
|
||||
while (lo < hi) {
|
||||
val mid = (lo + hi) ushr 1
|
||||
if (messages[mid].timestamp.time <= ts) lo = mid + 1 else hi = mid
|
||||
}
|
||||
messages.add(lo, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new list containing [messages] plus [message], kept sorted by timestamp (stable).
|
||||
*/
|
||||
fun withMessageInserted(messages: List<BitchatMessage>, message: BitchatMessage): List<BitchatMessage> {
|
||||
val result = messages.toMutableList()
|
||||
insertByTimestamp(result, message)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.util.MessageOrdering
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Regression tests for issue #525 (dups #425/#420/#302): incoming mesh messages were shown in
|
||||
* receive order instead of by their source packet timestamp, so a store-forwarded/gossip-synced
|
||||
* backlog appended at the bottom of the timeline interleaved with current messages.
|
||||
*/
|
||||
class MessageOrderingTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
private fun msg(id: String, tsMillis: Long, content: String = "m-$id"): BitchatMessage =
|
||||
BitchatMessage(
|
||||
id = id,
|
||||
sender = "alice",
|
||||
content = content,
|
||||
timestamp = Date(tsMillis),
|
||||
senderPeerID = "1122334455667788"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `public timeline orders a store-forwarded backlog by timestamp`() {
|
||||
val now = 1_700_000_000_000L
|
||||
// A current message is already on screen...
|
||||
val current = msg("current", now, content = "current")
|
||||
// ...then a peer reconnects and replays an hour-old backlog out of order.
|
||||
val oldA = msg("old-a", now - 3_600_000L, content = "old-a")
|
||||
val oldB = msg("old-b", now - 1_800_000L, content = "old-b")
|
||||
|
||||
AppStateStore.addPublicMessage(current)
|
||||
AppStateStore.addPublicMessage(oldB)
|
||||
AppStateStore.addPublicMessage(oldA)
|
||||
|
||||
val result = AppStateStore.publicMessages.value
|
||||
assertEquals(listOf(oldA, oldB, current), result)
|
||||
// Timestamps must be non-decreasing.
|
||||
assertEquals(result.sortedBy { it.timestamp.time }, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `channel timeline orders messages by timestamp`() {
|
||||
val now = 1_700_000_000_000L
|
||||
val c0 = msg("c0", now)
|
||||
val cOld = msg("c-old", now - 5_000L)
|
||||
val cMid = msg("c-mid", now - 2_000L)
|
||||
|
||||
AppStateStore.addChannelMessage("#general", c0)
|
||||
AppStateStore.addChannelMessage("#general", cOld)
|
||||
AppStateStore.addChannelMessage("#general", cMid)
|
||||
|
||||
assertEquals(
|
||||
listOf(cOld, cMid, c0),
|
||||
AppStateStore.channelMessages.value["#general"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dedup still drops repeats while keeping timestamp order`() {
|
||||
val now = 1_700_000_000_000L
|
||||
val a = msg("a", now)
|
||||
val b = msg("b", now - 1_000L)
|
||||
|
||||
AppStateStore.addPublicMessage(a)
|
||||
AppStateStore.addPublicMessage(b)
|
||||
// Same id arriving again over a second transport path must be ignored.
|
||||
AppStateStore.addPublicMessage(a.copy(content = "dup-by-id"))
|
||||
// Same sender/timestamp/content with a fresh android id (request-sync replay) must be ignored.
|
||||
AppStateStore.addPublicMessage(b.copy(id = "b-replay"))
|
||||
|
||||
assertEquals(listOf(b, a), AppStateStore.publicMessages.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal timestamps keep insertion order (stable)`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val first = msg("first", ts, content = "first")
|
||||
val second = msg("second", ts, content = "second")
|
||||
val third = msg("third", ts, content = "third")
|
||||
|
||||
// Direct helper check: inserting three equal-timestamp messages preserves arrival order.
|
||||
val list = mutableListOf<BitchatMessage>()
|
||||
MessageOrdering.insertByTimestamp(list, first)
|
||||
MessageOrdering.insertByTimestamp(list, second)
|
||||
MessageOrdering.insertByTimestamp(list, third)
|
||||
assertEquals(listOf(first, second, third), list)
|
||||
|
||||
// And through the store add-path (distinct ids so dedup does not collapse them).
|
||||
AppStateStore.addPublicMessage(first)
|
||||
AppStateStore.addPublicMessage(second)
|
||||
AppStateStore.addPublicMessage(third)
|
||||
assertEquals(listOf(first, second, third), AppStateStore.publicMessages.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer equal-timestamp message sorts after existing ones`() {
|
||||
val ts = 1_700_000_000_000L
|
||||
val existing = msg("existing", ts)
|
||||
val older = msg("older", ts - 10_000L)
|
||||
|
||||
val list = mutableListOf(older, existing)
|
||||
val incomingSameAsExisting = msg("incoming", ts)
|
||||
MessageOrdering.insertByTimestamp(list, incomingSameAsExisting)
|
||||
|
||||
assertEquals(listOf(older, existing, incomingSameAsExisting), list)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user