Add Mesh Lab courier and sync scenarios

This commit is contained in:
callebtc 2026-08-18 01:04:45 +02:00
parent 39b3eaa999
commit 4ec89e0b79
3 changed files with 205 additions and 3 deletions

View File

@ -1,6 +1,7 @@
package com.bitchat.android.testhook
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
@ -15,7 +16,10 @@ import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.TransferProgressManager
import com.bitchat.android.mesh.CourierDepositTier
import com.bitchat.android.mesh.CourierStore
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.CourierEnvelope
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
@ -23,8 +27,10 @@ import com.bitchat.android.service.MeshForegroundService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ConversationStorageCipher
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.PrivateMediaRecipientResolver
import com.bitchat.android.ui.debug.DebugSettingsManager
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -89,7 +95,10 @@ object TestHookDriver {
"ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(context, intent)
"courier_contract" -> courierContract(context)
"sync_request" -> syncRequest(intent.requiredString("peer"))
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
"wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"state" -> state(context)
"clear_results" -> clearResults(context)
@ -276,10 +285,11 @@ object TestHookDriver {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains")
val channel = intent.getStringExtra("channel")
val includeExisting = intent.getBooleanExtra("include_existing", false)
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.timestamp.time >= startTime &&
(includeExisting || msg.timestamp.time >= startTime) &&
msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains)) &&
(channel == null || msg.channel == channel)
@ -653,6 +663,109 @@ object TestHookDriver {
.put("peer", peerID)
}
/**
* Exercises the real courier wire/store implementation on a physical debug build.
* This is intentionally local: the two-phone harness has no third identity to act as
* both recipient and an independent courier, so transport scenarios cannot observe the
* spray budget without weakening the assertion.
*/
private fun courierContract(context: Context): JSONObject {
val filesDir = File(context.cacheDir, "testhook/courier-contract-files")
filesDir.deleteRecursively()
filesDir.mkdirs()
val labContext = LabStorageContext(context, filesDir)
val cipher = LabCipher(0x5a)
val now = System.currentTimeMillis()
val recipientKey = ByteArray(32) { 7 }
val depositorKey = ByteArray(32) { 8 }
val firstCourier = ByteArray(32) { 11 }
val secondCourier = ByteArray(32) { 12 }
val thirdCourier = ByteArray(32) { 13 }
val fourthCourier = ByteArray(32) { 14 }
val tag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now))
val store = CourierStore(labContext, cipher) { now }
try {
val firstEnvelope = CourierEnvelope(
recipientTag = tag,
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = ByteArray(32) { (it + 1).toByte() },
copies = 4u,
prekeyID = 0x11223344u
)
require(store.deposit(firstEnvelope, depositorKey, CourierDepositTier.VERIFIED))
val wire = requireNotNull(firstEnvelope.encode())
val decoded = requireNotNull(CourierEnvelope.decode(wire))
val first = store.sprayCopiesFor(firstCourier).single()
val second = store.sprayCopiesFor(secondCourier).single()
val sameCourierEmpty = store.sprayCopiesFor(firstCourier).isEmpty()
val secondCommitted = store.commitSpray(second, secondCourier)
val firstCommitted = store.commitSpray(first, firstCourier)
val thirdCourierEmpty = store.sprayCopiesFor(thirdCourier).isEmpty()
val secondEnvelope = firstEnvelope.copy(ciphertext = ByteArray(32) { (it + 65).toByte() })
require(store.deposit(secondEnvelope, depositorKey, CourierDepositTier.VERIFIED))
val cancelledPreview = store.sprayCopiesFor(fourthCourier).single()
val cancelled = store.cancelSpray(cancelledPreview, fourthCourier)
val retry = store.sprayCopiesFor(fourthCourier).single()
val retryCommitted = store.commitSpray(retry, fourthCourier)
val reloaded = CourierStore(labContext, LabCipher(0x5a)) { now }
val persistedSprayHistory = reloaded.sprayCopiesFor(fourthCourier)
.none { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
val remainingCopies = reloaded.sprayCopiesFor(thirdCourier)
.firstOrNull { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
?.copies
?.toInt()
reloaded.wipe()
require(decoded.prekeyID == firstEnvelope.prekeyID)
require(decoded.encode()?.contentEquals(wire) == true)
require(first.prekeyID == firstEnvelope.prekeyID)
require(first.copies == 2u.toUByte())
require(second.copies == 1u.toUByte())
require(sameCourierEmpty)
require(secondCommitted && firstCommitted && thirdCourierEmpty)
require(cancelled && retry.copies == 2u.toUByte() && retryCommitted)
require(persistedSprayHistory && remainingCopies == 1)
return ok("courier_contract")
.put("wire_prekey_id_preserved", true)
.put("stored_prekey_id_preserved", true)
.put("first_reserved_copies", first.copies.toInt())
.put("second_reserved_copies", second.copies.toInt())
.put("same_courier_second_reservation_empty", sameCourierEmpty)
.put("reverse_order_commits", true)
.put("cancel_restored_eligibility", cancelled)
.put("persisted_spray_history", persistedSprayHistory)
.put("remaining_copies_after_restart", remainingCopies)
} finally {
store.wipe()
filesDir.deleteRecursively()
}
}
private fun syncRequest(peerID: String): JSONObject {
val manager = MeshServiceHolder.sharedGossipSyncManager
?: return err("sync_request", "gossip sync manager is unavailable")
manager.scheduleInitialSyncToPeer(peerID, 0)
return ok("sync_request").put("peer", peerID)
}
private class LabStorageContext(base: Context, private val labFilesDir: File) : ContextWrapper(base) {
override fun getApplicationContext(): Context = this
override fun getFilesDir(): File = labFilesDir
}
private class LabCipher(private val mask: Int) : ConversationStorageCipher {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray =
encrypt(envelope, associatedData)
override fun destroyKey() = Unit
}
// MARK: - Transport / state
private fun setBle(enabled: Boolean): JSONObject {
@ -661,6 +774,14 @@ object TestHookDriver {
return ok("ble").put("enabled", enabled)
}
private fun setWifiAware(enabled: Boolean): JSONObject {
val previous = com.bitchat.android.wifiaware.WifiAwareController.enabled.value
DebugSettingsManager.getInstance().setWifiAwareEnabled(enabled)
return ok("wifi_aware")
.put("enabled", enabled)
.put("previous_enabled", previous)
}
private fun state(context: Context): JSONObject {
val mesh = mesh(context)
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)

View File

@ -298,6 +298,8 @@ python3 tools/release_gate/mesh_lab.py scenario all \
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
| `broadcast` | public mesh message A→B |
| `sync_recovery` | message missed while B's BLE and Wi-Fi Aware transports are disabled is recovered by the gossip sync request after reconnection |
| `courier_contract` | on-device courier wire/store contract: prekey TLV retention, split reservations, cancellation, reverse commits, and persisted spray history |
| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption |
| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions |
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
@ -328,8 +330,12 @@ See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`,
`favorite_status`, `verification_set`, `verification_status`, `file_send`,
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`,
`clear_results`).
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `courier_contract`,
`sync_request`, `ble`, `wifi_aware`, `state`, `clear_results`).
`msg_recv` accepts `include_existing=true` for deterministic backfill checks when
the packet's original timestamp predates the receive hook; use a unique synthetic
content token with this mode.
### Troubleshooting

View File

@ -532,6 +532,79 @@ def scenario_broadcast(a: Device, b: Device) -> dict:
return {"send": send_result, "recv": recv_result}
def scenario_sync_recovery(a: Device, b: Device) -> dict:
"""Miss a public message while B's transports are offline, then receive it via gossip sync."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
token = f"sync-{uuid.uuid4().hex[:8]}"
b.cmd_ok("ble", enabled=False)
wifi_before = b.cmd_ok("wifi_aware", enabled=False)["previous_enabled"]
try:
send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"sync recovery {token}")
# The hook returns after dispatching the send coroutine. Give the sender's
# gossip observer time to retain the packet before taking B offline.
time.sleep(2)
offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True)
if offline_result.get("status") == "ok":
raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}")
# Restart B so the initial sync request is scheduled from a fresh peer
# observation instead of relying on an already-cached relationship.
b.force_stop()
b.wake()
b.launch()
b.cmd_ok("start")
b.cmd_ok("ble", enabled=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(
b.cmd_ok,
"msg_recv",
180_000,
contains=token,
include_existing=True,
)
time.sleep(2)
ensure_direct_link(a, b, id_a, id_b)
b.cmd_ok("sync_request", peer=id_a)
recv_result = recv.result()
assert recv_result["from"] == id_a, recv_result
return {
"send": send_result,
"offline_delivery": {"status": offline_result.get("status", "missing")},
"synced_recv": recv_result,
}
finally:
# Leave the pair usable if an assertion or timeout interrupts the recovery.
try:
b.cmd_ok("ble", enabled=True)
except MeshLabError:
pass
try:
b.cmd_ok("wifi_aware", enabled=wifi_before)
except MeshLabError:
pass
def scenario_courier_contract(a: Device, b: Device) -> dict:
"""Run the real courier wire/store contract on device A's debug build."""
result = a.cmd_ok("courier_contract", 60_000)
expected = {
"wire_prekey_id_preserved": True,
"stored_prekey_id_preserved": True,
"first_reserved_copies": 2,
"second_reserved_copies": 1,
"same_courier_second_reservation_empty": True,
"reverse_order_commits": True,
"cancel_restored_eligibility": True,
"persisted_spray_history": True,
"remaining_copies_after_restart": 1,
}
for field, value in expected.items():
if result.get(field) != value:
raise MeshLabError(f"courier contract field {field}={result.get(field)!r}, expected {value!r}")
return result
def _ptt_one_way(
sender: Device,
receiver: Device,
@ -869,6 +942,8 @@ SCENARIOS = {
"dm": scenario_dm,
"favorite_verification": scenario_favorite_verification,
"broadcast": scenario_broadcast,
"sync_recovery": scenario_sync_recovery,
"courier_contract": scenario_courier_contract,
"ptt_dm": scenario_ptt_dm,
"ptt_broadcast": scenario_ptt_broadcast,
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only