From 0c0b16cfedecd1708875cf3196a7cbfdd5a7bfdf Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:55:10 +0200 Subject: [PATCH] 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. --- .../android/testhook/ArrivedMessageMatcher.kt | 20 +++++++++ .../android/testhook/TestHookDriver.kt | 35 ++++++--------- .../testhook/ArrivedMessageMatcherTest.kt | 45 +++++++++++++++++++ .../watch/testhook/ArrivedMessageMatcher.kt | 20 +++++++++ .../watch/testhook/WearTestHookDriver.kt | 30 +++++-------- 5 files changed, 110 insertions(+), 40 deletions(-) create mode 100644 app/src/debug/java/com/bitchat/android/testhook/ArrivedMessageMatcher.kt create mode 100644 app/src/test/java/com/bitchat/android/testhook/ArrivedMessageMatcherTest.kt create mode 100644 wear/src/debug/java/com/bitchat/watch/testhook/ArrivedMessageMatcher.kt diff --git a/app/src/debug/java/com/bitchat/android/testhook/ArrivedMessageMatcher.kt b/app/src/debug/java/com/bitchat/android/testhook/ArrivedMessageMatcher.kt new file mode 100644 index 000000000..77c567665 --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/ArrivedMessageMatcher.kt @@ -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, + private val accepts: (BitchatMessage) -> Boolean +) { + private val seenIDs: Set = alreadyPresent.mapTo(HashSet()) { it.id } + + fun firstArrived(messages: Iterable): BitchatMessage? = + messages.firstOrNull { it.id !in seenIDs && accepts(it) } +} diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt index 7379b5efa..8eba3b62c 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -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") diff --git a/app/src/test/java/com/bitchat/android/testhook/ArrivedMessageMatcherTest.kt b/app/src/test/java/com/bitchat/android/testhook/ArrivedMessageMatcherTest.kt new file mode 100644 index 000000000..61f246e25 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/testhook/ArrivedMessageMatcherTest.kt @@ -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))) + } +} diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/ArrivedMessageMatcher.kt b/wear/src/debug/java/com/bitchat/watch/testhook/ArrivedMessageMatcher.kt new file mode 100644 index 000000000..ccbc05e4f --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/ArrivedMessageMatcher.kt @@ -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, + private val accepts: (BitchatMessage) -> Boolean +) { + private val seenIDs: Set = alreadyPresent.mapTo(HashSet()) { it.id } + + fun firstArrived(messages: Iterable): BitchatMessage? = + messages.firstOrNull { it.id !in seenIDs && accepts(it) } +} diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt index 1f6d5cd8c..a2c423a01 100644 --- a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -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)