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 7379b5ef..4323f9c5 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -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) diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 8de1e063..2bf94928 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -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 diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 7abe8085..0139d99a 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -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