Make Mesh Lab receive hooks tolerate device clock skew

dm_recv and msg_recv compared the sender's message timestamp with the
receiver's clock at command start, so a receiver whose clock ran ahead
ignored messages that had genuinely arrived. Decide "new" by local
arrival instead: snapshot the message IDs present when the command
starts and match only unseen IDs. Mirrored in the Wear debug driver.
This commit is contained in:
callebtc 2026-10-25 01:55:10 +02:00
parent 95c0852c2b
commit 0c0b16cfed
5 changed files with 110 additions and 40 deletions

View File

@ -0,0 +1,20 @@
package com.bitchat.android.testhook
import com.bitchat.android.model.BitchatMessage
/**
* Finds messages that arrived after a receive command started.
*
* "Arrived" is decided locally: a message is new when its ID was absent from the
* snapshot taken at command start. The sender's timestamp is never compared with the
* receiver's clock, so receive hooks keep working when the two devices' clocks disagree.
*/
internal class ArrivedMessageMatcher(
alreadyPresent: Iterable<BitchatMessage>,
private val accepts: (BitchatMessage) -> Boolean
) {
private val seenIDs: Set<String> = alreadyPresent.mapTo(HashSet()) { it.id }
fun firstArrived(messages: Iterable<BitchatMessage>): BitchatMessage? =
messages.firstOrNull { it.id !in seenIDs && accepts(it) }
}

View File

@ -31,6 +31,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
@ -247,24 +248,17 @@ object TestHookDriver {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val contains = intent.getStringExtra("contains")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val match = withTimeoutOrNull(timeoutMs) {
AppStateStore.privateMessages.first { conversations ->
conversations.values.flatten().any { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
}
} ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
val msg = match.values.flatten().first { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
val matcher = ArrivedMessageMatcher(AppStateStore.privateMessages.value.values.flatten()) { msg ->
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
val msg = withTimeoutOrNull(timeoutMs) {
AppStateStore.privateMessages
.mapNotNull { conversations -> matcher.firstArrived(conversations.values.flatten()) }
.first()
} ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
return ok("dm_recv")
.put("from", msg.senderPeerID)
.put("sender", msg.sender)
@ -276,20 +270,19 @@ object TestHookDriver {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains")
val channel = intent.getStringExtra("channel")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
val accepts: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains)) &&
(channel == null || msg.channel == channel)
}
val found = withTimeoutOrNull(timeoutMs) {
if (channel != null) {
AppStateStore.channelMessages.first { m -> m.values.flatten().any(matches) }
.values.flatten().first(matches)
val matcher = ArrivedMessageMatcher(AppStateStore.channelMessages.value.values.flatten(), accepts)
AppStateStore.channelMessages.mapNotNull { m -> matcher.firstArrived(m.values.flatten()) }.first()
} else {
AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches)
val matcher = ArrivedMessageMatcher(AppStateStore.publicMessages.value, accepts)
AppStateStore.publicMessages.mapNotNull(matcher::firstArrived).first()
}
} ?: return err("msg_recv", "timeout after ${timeoutMs}ms")
return ok("msg_recv")

View File

@ -0,0 +1,45 @@
package com.bitchat.android.testhook
import com.bitchat.android.model.BitchatMessage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.Date
class ArrivedMessageMatcherTest {
private val receiverClockAtStart = 1_700_000_000_000L
private fun message(id: String, content: String, timestampMs: Long) = BitchatMessage(
id = id,
sender = "peer",
content = content,
timestamp = Date(timestampMs),
senderPeerID = "0102030405060708"
)
@Test
fun messageStampedBySlowerSenderClockStillCountsAsArrived() {
val matcher = ArrivedMessageMatcher(emptyList()) { it.content.contains("token") }
val behindReceiver = message("new", "hello token", receiverClockAtStart - 140_000)
assertEquals(behindReceiver, matcher.firstArrived(listOf(behindReceiver)))
}
@Test
fun messagePresentAtStartIsIgnoredEvenWhenSenderClockIsAhead() {
val preexisting = message("old", "hello token", receiverClockAtStart + 140_000)
val matcher = ArrivedMessageMatcher(listOf(preexisting)) { it.content.contains("token") }
assertNull(matcher.firstArrived(listOf(preexisting)))
}
@Test
fun arrivedMessageMustStillPassFilter() {
val matcher = ArrivedMessageMatcher(emptyList()) { it.content.contains("token") }
val unrelated = message("a", "other", receiverClockAtStart)
val wanted = message("b", "hello token", receiverClockAtStart)
assertEquals(wanted, matcher.firstArrived(listOf(unrelated, wanted)))
}
}

View File

@ -0,0 +1,20 @@
package com.bitchat.watch.testhook
import com.bitchat.android.model.BitchatMessage
/**
* Finds messages that arrived after a receive command started.
*
* "Arrived" is decided locally: a message is new when its ID was absent from the
* snapshot taken at command start. The sender's timestamp is never compared with the
* receiver's clock, so receive hooks keep working when the two devices' clocks disagree.
*/
internal class ArrivedMessageMatcher(
alreadyPresent: Iterable<BitchatMessage>,
private val accepts: (BitchatMessage) -> Boolean
) {
private val seenIDs: Set<String> = alreadyPresent.mapTo(HashSet()) { it.id }
fun firstArrived(messages: Iterable<BitchatMessage>): BitchatMessage? =
messages.firstOrNull { it.id !in seenIDs && accepts(it) }
}

View File

@ -20,6 +20,7 @@ import com.bitchat.watch.service.WearMeshForegroundService
import com.bitchat.watch.ui.WearPeerIdentityState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
@ -229,24 +230,17 @@ object WearTestHookDriver {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val contains = intent.getStringExtra("contains")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val match = withTimeoutOrNull(timeoutMs) {
AppStateStore.privateMessages.first { conversations ->
conversations.values.flatten().any { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
}
} ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
val msg = match.values.flatten().first { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
val matcher = ArrivedMessageMatcher(AppStateStore.privateMessages.value.values.flatten()) { msg ->
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
val msg = withTimeoutOrNull(timeoutMs) {
AppStateStore.privateMessages
.mapNotNull { conversations -> matcher.firstArrived(conversations.values.flatten()) }
.first()
} ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
return ok("dm_recv")
.put("from", msg.senderPeerID)
.put("sender", msg.sender)
@ -257,15 +251,13 @@ object WearTestHookDriver {
private suspend fun msgRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
val matcher = ArrivedMessageMatcher(AppStateStore.publicMessages.value) { msg ->
msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains))
}
val found = withTimeoutOrNull(timeoutMs) {
AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches)
AppStateStore.publicMessages.mapNotNull(matcher::firstArrived).first()
} ?: return err("msg_recv", "timeout after ${timeoutMs}ms")
return ok("msg_recv")
.put("from", found.senderPeerID)