From 41494d7c167bf0d8a0235a86c84b220ebda6f6cd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:05:49 +0200 Subject: [PATCH 1/5] Add ADB-driven mesh test-hook framework and two-device mesh lab Debug-only broadcast receiver (app/src/debug) exposes mesh operations over ADB: scan, connect, Noise handshake, DMs, broadcast, announce, file send/receive with SHA-256 verification, BLE toggle, state dumps, and raw packet injection. tools/release_gate/mesh_lab.py orchestrates scenarios (dm, broadcast, file, file_private, raw) on two live devices and emits evidence JSON. --- app/src/debug/AndroidManifest.xml | 19 + .../android/testhook/TestHookDriver.kt | 469 ++++++++++++++++++ .../android/testhook/TestHookReceiver.kt | 65 +++ docs/release-gate-runbook.md | 29 ++ tools/release_gate/mesh_lab.py | 421 ++++++++++++++++ 5 files changed, 1003 insertions(+) create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt create mode 100644 app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt create mode 100644 tools/release_gate/mesh_lab.py diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..e84ea5db --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt new file mode 100644 index 00000000..de3f92b8 --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -0,0 +1,469 @@ +package com.bitchat.android.testhook + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.bitchat.android.features.file.FileUtils +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PrivateMediaPreparation +import com.bitchat.android.mesh.TransferProgressManager +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.protocol.BitchatPacket +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.ui.DataManager +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest + +/** + * Headless engine behind [TestHookReceiver]. Drives the public [MeshService] API and + * observes state via [AppStateStore] flows (never touches the single-slot mesh delegate). + */ +object TestHookDriver { + + private const val TAG = TestHookReceiver.TAG + + private const val DEFAULT_SCAN_TIMEOUT_MS = 30_000L + private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L + private const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000L + private const val DEFAULT_RECV_TIMEOUT_MS = 60_000L + private const val DEFAULT_FILE_TIMEOUT_MS = 180_000L + + suspend fun execute(context: Context, cmd: String, intent: Intent): JSONObject { + Log.d(TAG, "execute cmd=$cmd") + val result = when (cmd) { + "ping" -> ok(cmd).put("pong", true).put("package", context.packageName) + "start" -> start(context) + "stop" -> stop(context) + "whoami" -> whoami(context) + "set_nickname" -> setNickname(context, intent.requiredString("name")) + "scan" -> scan(context, intent) + "peers" -> peers(context) + "connect" -> connect(intent.requiredString("peer"), intent) + "handshake" -> handshake(context, intent.requiredString("peer"), intent) + "session" -> session(context, intent.requiredString("peer")) + "announce" -> announce(context) + "broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel")) + "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) + "dm_recv" -> dmRecv(context, intent) + "msg_recv" -> msgRecv(context, intent) + "file_send" -> fileSend(context, intent) + "file_recv" -> fileRecv(context, intent) + "file_cancel" -> fileCancel(context, intent.requiredString("transfer_id")) + "raw_send" -> rawSend(context, intent) + "ble" -> setBle(intent.getBooleanExtra("enabled", true)) + "state" -> state(context) + "clear_results" -> clearResults(context) + else -> err(cmd, "unknown command: $cmd") + } + return result.put("cmd", cmd) + } + + // MARK: - Lifecycle + + private fun start(context: Context): JSONObject { + MeshForegroundService.start(context) + val mesh = mesh(context) + mesh.startServices() + return ok("start").put("peer_id", mesh.myPeerID) + } + + private fun stop(context: Context): JSONObject { + try { + MeshServiceHolder.unifiedMeshService?.stopServices() + } catch (e: Exception) { + Log.w(TAG, "stopServices failed: ${e.message}") + } + MeshForegroundService.stop(context) + return ok("stop") + } + + // MARK: - Identity + + private fun whoami(context: Context): JSONObject { + val mesh = mesh(context) + return ok("whoami") + .put("peer_id", mesh.myPeerID) + .put("identity_fingerprint", mesh.getIdentityFingerprint()) + .put("noise_public_key", mesh.getStaticNoisePublicKey()?.toHex()) + .put("nickname", AppStateStore.nickname.value) + } + + private fun setNickname(context: Context, name: String): JSONObject { + DataManager(context).saveNickname(name) + AppStateStore.setNickname(name) + mesh(context).sendBroadcastAnnounce() + return ok("set_nickname").put("nickname", name) + } + + // MARK: - Discovery / connection + + private suspend fun scan(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_SCAN_TIMEOUT_MS) + val minPeers = intent.getIntExtra("min_peers", 1) + val mesh = mesh(context) + val found = withTimeoutOrNull(timeoutMs) { + AppStateStore.peers.first { it.size >= minPeers } + } + val peerIds = found ?: AppStateStore.peers.value + return ok("scan") + .put("reached_min_peers", found != null) + .put("peers", peerInfosJson(mesh, peerIds)) + } + + private fun peers(context: Context): JSONObject { + val mesh = mesh(context) + return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value)) + } + + private suspend fun connect(peerID: String, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS) + val ble = MeshServiceHolder.meshService ?: return err("connect", "BLE service not running") + val address = ble.getDeviceAddressForPeer(peerID) + ?: return err("connect", "no device address known for peer $peerID (scan first)") + val accepted = ble.connectionManager.connectToAddress(address) + if (!accepted) return err("connect", "connectToAddress($address) rejected") + val direct = withTimeoutOrNull(timeoutMs) { + AppStateStore.directPeers.first { it.contains(peerID) } + } + return ok("connect") + .put("peer", peerID) + .put("address", address) + .put("direct", direct != null) + } + + // MARK: - Noise + + private suspend fun handshake(context: Context, peerID: String, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_HANDSHAKE_TIMEOUT_MS) + val mesh = mesh(context) + val deadline = System.currentTimeMillis() + timeoutMs + if (!mesh.hasEstablishedSession(peerID)) { + mesh.initiateNoiseHandshake(peerID) + } + var lastState: NoiseSession.NoiseSessionState = NoiseSession.NoiseSessionState.Uninitialized + while (System.currentTimeMillis() < deadline) { + lastState = mesh.getSessionState(peerID) + when (lastState) { + is NoiseSession.NoiseSessionState.Established -> { + return ok("handshake") + .put("peer", peerID) + .put("state", lastState.toString()) + .put("fingerprint", mesh.getPeerFingerprint(peerID)) + } + is NoiseSession.NoiseSessionState.Failed -> { + return err("handshake", "session failed: $lastState").put("peer", peerID) + } + else -> delay(100) + } + } + return err("handshake", "timeout after ${timeoutMs}ms (last state: $lastState)").put("peer", peerID) + } + + private fun session(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + return ok("session") + .put("peer", peerID) + .put("state", mesh.getSessionState(peerID).toString()) + .put("established", mesh.hasEstablishedSession(peerID)) + .put("fingerprint", mesh.getPeerFingerprint(peerID)) + } + + // MARK: - Messaging + + private fun announce(context: Context): JSONObject { + mesh(context).sendBroadcastAnnounce() + return ok("announce") + } + + private fun broadcastMsg(context: Context, content: String, channel: String?): JSONObject { + mesh(context).sendMessage(content, emptyList(), channel) + return ok("broadcast_msg").put("content", content).put("channel", channel) + } + + private fun dmSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject { + val mesh = mesh(context) + val nickname = mesh.getPeerNicknames()[peerID] ?: peerID + val id = msgID ?: "testhook-${System.currentTimeMillis()}" + mesh.sendPrivateMessage(content, peerID, nickname, id) + return ok("dm_send").put("peer", peerID).put("msg_id", id) + } + + private suspend fun dmRecv(context: Context, intent: Intent): JSONObject { + 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 && + (fromPeer == null || msg.senderPeerID == fromPeer) && + (contains == null || msg.content.contains(contains)) + } + return ok("dm_recv") + .put("from", msg.senderPeerID) + .put("sender", msg.sender) + .put("content", msg.content) + .put("msg_id", msg.id) + } + + 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 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 && + (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) + } else { + AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches) + } + } ?: return err("msg_recv", "timeout after ${timeoutMs}ms") + return ok("msg_recv") + .put("from", found.senderPeerID) + .put("sender", found.sender) + .put("content", found.content) + .put("channel", found.channel) + .put("msg_id", found.id) + } + + // MARK: - File transfer + + private suspend fun fileSend(context: Context, intent: Intent): JSONObject { + val path = intent.requiredString("path") + val peerID = intent.getStringExtra("peer") + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val mesh = mesh(context) + + val file = File(path) + if (!file.isFile) return err("file_send", "file not found: $path") + val content = withContext(Dispatchers.IO) { file.readBytes() } + if (content.size.toLong() > AppConstants.Media.MAX_FILE_SIZE_BYTES) { + return err("file_send", "file too large: ${content.size} > ${AppConstants.Media.MAX_FILE_SIZE_BYTES}") + } + val packet = BitchatFilePacket( + fileName = file.name, + fileSize = content.size.toLong(), + mimeType = intent.getStringExtra("mime") ?: FileUtils.getMimeTypeFromExtension(file.name), + content = content + ) + val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet") + val transferId = sha256Hex(encoded) + + if (peerID == null) { + mesh.sendFileBroadcast(packet) + } else { + if (!mesh.hasEstablishedSession(peerID)) { + val hs = handshake(context, peerID, intent) + if (hs.optString("status") != "ok") return hs.put("cmd", "file_send") + } + // Peer state (capabilities/identity) can lag session establishment; + // retry transient preparation states before giving up. + val prepDeadline = System.currentTimeMillis() + 30_000 + while (true) { + when (val prep = mesh.prepareFilePrivate(peerID, packet, transferId, allowLegacyFallback = false)) { + is PrivateMediaPreparation.Ready -> { + if (!prep.transfer.commit()) return err("file_send", "private transfer commit failed") + break + } + PrivateMediaPreparation.AwaitingPeerState, + PrivateMediaPreparation.NeedsHandshake -> { + if (System.currentTimeMillis() >= prepDeadline) { + return err("file_send", "private media preparation stuck at: $prep") + } + if (prep == PrivateMediaPreparation.NeedsHandshake) { + mesh.initiateNoiseHandshake(peerID) + } + delay(500) + } + else -> return err("file_send", "private media preparation: $prep") + } + } + } + + val event = withTimeoutOrNull(timeoutMs) { + TransferProgressManager.events.first { it.transferId == transferId && it.completed } + } ?: return err("file_send", "timeout waiting for transfer completion ($transferId)") + return ok("file_send") + .put("transfer_id", transferId) + .put("sent", event.sent) + .put("total", event.total) + .put("bytes", content.size) + .put("peer", peerID) + } + + private suspend fun fileRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val nameContains = intent.getStringExtra("name_contains") + val startTime = System.currentTimeMillis() + val dirs = listOf( + File(context.cacheDir, "files/incoming"), + File(context.cacheDir, "images/incoming") + ) + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val candidate = dirs + .flatMap { it.listFiles()?.toList() ?: emptyList() } + .filter { it.lastModified() >= startTime - 5_000 } + .filter { nameContains == null || it.name.contains(nameContains) } + .maxByOrNull { it.lastModified() } + if (candidate != null) { + val size1 = candidate.length() + delay(500) + if (candidate.length() == size1 && size1 > 0) { + return ok("file_recv") + .put("path", candidate.absolutePath) + .put("name", candidate.name) + .put("bytes", size1) + .put("sha256", withContext(Dispatchers.IO) { sha256Hex(candidate.readBytes()) }) + } + } + delay(250) + } + return err("file_recv", "timeout after ${timeoutMs}ms") + } + + private fun fileCancel(context: Context, transferId: String): JSONObject { + val cancelled = mesh(context).cancelFileTransfer(transferId) + return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled) + } + + // MARK: - Raw packet injection + + private fun rawSend(context: Context, intent: Intent): JSONObject { + val payloadHex = intent.requiredString("payload_hex") + val typeStr = intent.requiredString("type") + val peerID = intent.getStringExtra("peer") + val ttl = intent.getIntExtra("ttl", 7) + val type = typeStr.toUIntOrNull(16)?.toUByte() + ?: return err("raw_send", "invalid type hex: $typeStr") + val payload = hexToBytes(payloadHex) + ?: return err("raw_send", "invalid payload_hex") + val mesh = mesh(context) + val packet = BitchatPacket( + type = type, + ttl = ttl.toUByte(), + senderID = mesh.myPeerID, + payload = payload + ) + if (peerID != null) { + TransportBridgeService.sendToPeerFromLocal(peerID, packet) + } else { + TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) + } + return ok("raw_send") + .put("type", typeStr) + .put("payload_bytes", payload.size) + .put("peer", peerID) + } + + // MARK: - Transport / state + + private fun setBle(enabled: Boolean): JSONObject { + val ble = MeshServiceHolder.meshService ?: return err("ble", "BLE service not running") + ble.setBleTransportEnabled(enabled) + return ok("ble").put("enabled", enabled) + } + + private fun state(context: Context): JSONObject { + val mesh = mesh(context) + val peersJson = peerInfosJson(mesh, AppStateStore.peers.value) + val sessions = JSONObject() + AppStateStore.peers.value.forEach { peerID -> + sessions.put(peerID, mesh.getSessionState(peerID).toString()) + } + return ok("state") + .put("peer_id", mesh.myPeerID) + .put("nickname", AppStateStore.nickname.value) + .put("peers", peersJson) + .put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList())) + .put("sessions", sessions) + .put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>)) + .put("debug_status", mesh.getDebugStatus()) + } + + private fun clearResults(context: Context): JSONObject { + val dir = File(context.cacheDir, "testhook/results") + val count = dir.listFiles()?.count { it.delete() } ?: 0 + return ok("clear_results").put("deleted", count) + } + + // MARK: - Helpers + + private fun mesh(context: Context): MeshService = MeshServiceHolder.getUnifiedOrCreate(context) + + private fun peerInfosJson(mesh: MeshService, peerIds: List): JSONArray { + val nicknames = mesh.getPeerNicknames() + val rssi = mesh.getPeerRSSI() + val arr = JSONArray() + peerIds.forEach { id -> + val info = mesh.getPeerInfo(id) + arr.put(JSONObject() + .put("id", id) + .put("nickname", nicknames[id] ?: info?.nickname) + .put("rssi", rssi[id]) + .put("direct", AppStateStore.directPeers.value.contains(id)) + .put("connected", info?.isConnected) + .put("last_seen", info?.lastSeen) + .put("session", mesh.getSessionState(id).toString()) + .put("fingerprint", mesh.getPeerFingerprint(id))) + } + return arr + } + + private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd) + private fun err(cmd: String, message: String) = + JSONObject().put("status", "error").put("cmd", cmd).put("error", message) + + private fun Intent.requiredString(name: String): String = + getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name") + + private fun sha256Hex(data: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(data).toHex() + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun hexToBytes(hex: String): ByteArray? { + val clean = hex.replace(" ", "") + if (clean.length % 2 != 0) return null + return try { + ByteArray(clean.length / 2) { i -> + clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt new file mode 100644 index 00000000..3f549d5d --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.testhook + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import java.io.File + +/** + * ADB-drivable test hook (debug builds only). + * + * Usage: + * adb shell am broadcast -a com.bitchat.droid.TEST_HOOK \ + * --es cmd --es id [command extras...] + * + * Result is written to cache/testhook/results/.json and logged under tag TestHook: + * adb shell run-as com.bitchat.droid cat cache/testhook/results/.json + */ +class TestHookReceiver : BroadcastReceiver() { + + companion object { + const val TAG = "TestHook" + const val ACTION = "com.bitchat.droid.TEST_HOOK" + private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION) return + val cmd = intent.getStringExtra("cmd") ?: "ping" + val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}" + val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS) + + Log.i(TAG, "CMD id=$id cmd=$cmd") + + val pendingResult = goAsync() + Thread { + val result = try { + runBlocking { + withTimeout(overallTimeout) { + TestHookDriver.execute(context.applicationContext, cmd, intent) + } + } + } catch (e: Exception) { + JSONObject() + .put("status", "error") + .put("cmd", cmd) + .put("error", "${e.javaClass.simpleName}: ${e.message}") + } + try { + val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() } + File(dir, "$id.json").writeText(result.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to write result file for $id: ${e.message}") + } + Log.i(TAG, "RESULT id=$id $result") + }.start() + // Finish immediately: long-running commands continue on the worker thread and + // report via the result file. Holding the broadcast open past the system + // broadcast window would ANR the app. + pendingResult.finish() + } +} diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 414bfbae..90e58deb 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -242,3 +242,32 @@ command and stop the local relay/Tor fixture. not waive a mandatory scenario. - A flaky result is a failure until its cause is understood. Never average retries into a pass. + +## Appendix: mesh lab (ADB test hooks, debug builds) + +For day-to-day development there is a lighter-weight harness that drives a +debug-only broadcast receiver (`app/src/debug/`, never shipped in release) +exposing mesh operations over ADB: scan, connect, Noise handshake, DMs, +public broadcast, announce, file send/receive, BLE toggle, state dumps, and +raw packet injection. Results are JSON files in the app sandbox polled by the +host (`cache/testhook/results/.json`, also logged under tag `TestHook`). + +```sh +# install + grant + launch + nickname + mutual discovery on two devices +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a --serial-b \ + --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk + +# scenarios: dm, broadcast, file, file_private, raw, all +python3 tools/release_gate/mesh_lab.py scenario file \ + --serial-a --serial-b --out /tmp/meshlab-evidence + +# single command against one device +python3 tools/release_gate/mesh_lab.py cmd --serial scan \ + --extra timeout_ms=30000 +``` + +The file scenarios push deterministic fixtures into the app sandbox and verify +the receiver's saved file by SHA-256. Unlike the release gate, this harness is +a development aid: it prints raw diagnostics and does not produce a +privacy-checked approval bundle. diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py new file mode 100644 index 00000000..d1afe470 --- /dev/null +++ b/tools/release_gate/mesh_lab.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""ADB-driven mesh test orchestrator for two (or more) live devices. + +Drives the debug-only TestHookReceiver in the app +(intent action: com.bitchat.droid.TEST_HOOK) to perform mesh operations: +peer scanning, connect, Noise handshake, DMs, file transfer, broadcast, +announce, and raw packet injection. + +Each on-device command writes a JSON result to +cache/testhook/results/.json inside the app sandbox; this module polls +for it via `run-as` and returns the parsed dict. + +Typical usage: + python3 tools/release_gate/mesh_lab.py setup --serial-a X --serial-b Y --apk app/build/outputs/apk/debug/app-debug.apk + python3 tools/release_gate/mesh_lab.py scenario dm --serial-a X --serial-b Y + python3 tools/release_gate/mesh_lab.py scenario all --serial-a X --serial-b Y + python3 tools/release_gate/mesh_lab.py cmd --serial X scan --extra timeout_ms=30000 +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import hashlib +import json +import random +import shlex +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from tools.release_gate.android_lab import APPLICATION_ID, find_adb, run_adb + +TEST_HOOK_ACTION = "com.bitchat.droid.TEST_HOOK" +TEST_HOOK_COMPONENT = f"{APPLICATION_ID}/com.bitchat.android.testhook.TestHookReceiver" +RESULTS_DIR = "cache/testhook/results" +DEVICE_TMP_DIR = "/data/local/tmp/meshlab" +APP_FIXTURE_DIR = f"/data/data/{APPLICATION_ID}/cache/fixtures" + +PERMISSIONS = [ + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.POST_NOTIFICATIONS", + "android.permission.NEARBY_WIFI_DEVICES", + "android.permission.RECORD_AUDIO", +] + + +class MeshLabError(Exception): + pass + + +def _shell(serial: str, command: str) -> str: + return run_adb(serial, ["shell", command]) + + +class Device: + """One ADB-connected phone running a debug build with the test hook.""" + + def __init__(self, serial: str, alias: str): + self.serial = serial + self.alias = alias + + # -- app lifecycle ------------------------------------------------------ + + def install(self, apk: Path) -> None: + result = subprocess.run( + [find_adb(), "-s", self.serial, "install", "-r", "-g", str(apk)], + check=False, capture_output=True, text=True, timeout=300, + ) + if result.returncode != 0 or "Success" not in result.stdout: + raise MeshLabError(f"[{self.alias}] install failed: {result.stdout} {result.stderr}") + + def grant_permissions(self) -> None: + for perm in PERMISSIONS: + subprocess.run( + [find_adb(), "-s", self.serial, "shell", "pm", "grant", APPLICATION_ID, perm], + check=False, capture_output=True, text=True, timeout=30, + ) + + def clear_app_data(self) -> None: + _shell(self.serial, f"am force-stop {APPLICATION_ID}") + output = _shell(self.serial, f"pm clear {APPLICATION_ID}") + if "Success" not in output: + raise MeshLabError(f"[{self.alias}] pm clear failed: {output}") + + def launch(self) -> None: + _shell(self.serial, f"monkey -p {APPLICATION_ID} -c android.intent.category.LAUNCHER 1") + time.sleep(3) + + def enable_bluetooth(self) -> None: + subprocess.run( + [find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"], + check=False, capture_output=True, text=True, timeout=30, + ) + + # -- fixtures ----------------------------------------------------------- + + def push_fixture(self, local: Path, name: str | None = None) -> str: + """Stage a fixture inside the app sandbox and return its app-readable path. + + adb push lands files as shell:ext_data_rw, which the app cannot read + through the FUSE Android/data mount, so the bytes are piped through + the shell into the app's own cache directory via run-as. + """ + fname = name or local.name + tmp = f"{DEVICE_TMP_DIR}/{fname}" + _shell(self.serial, f"mkdir -p {DEVICE_TMP_DIR}") + result = subprocess.run( + [find_adb(), "-s", self.serial, "push", str(local), tmp], + check=False, capture_output=True, text=True, timeout=120, + ) + if result.returncode != 0: + raise MeshLabError(f"[{self.alias}] push failed: {result.stderr}") + target = f"{APP_FIXTURE_DIR}/{fname}" + _shell( + self.serial, + f"run-as {APPLICATION_ID} mkdir -p {APP_FIXTURE_DIR} && " + f"cat {tmp} | run-as {APPLICATION_ID} sh -c 'cat > {target}' && rm -f {tmp}", + ) + return target + + # -- test hook commands ------------------------------------------------- + + def cmd(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict: + """Send a test-hook command and poll for its JSON result.""" + cmd_id = uuid.uuid4().hex[:12] + _shell(self.serial, f"run-as {APPLICATION_ID} rm -f {RESULTS_DIR}/{cmd_id}.json") + + args = [ + "am", "broadcast", "-a", TEST_HOOK_ACTION, + "-n", TEST_HOOK_COMPONENT, + "--es", "cmd", cmd, + "--es", "id", cmd_id, + "--el", "timeout_ms", str(timeout_ms), + "--el", "overall_timeout_ms", str(timeout_ms + 30_000), + ] + for key, value in extras.items(): + if value is None: + continue + if isinstance(value, bool): + args += ["--ez", key, "true" if value else "false"] + elif isinstance(value, int): + args += ["--el", key, str(value)] + else: + args += ["--es", key, str(value)] + try: + _shell(self.serial, " ".join(shlex.quote(a) for a in args)) + except Exception as error: + # The shell occasionally hangs even though the broadcast was delivered; + # fall through to result polling, which is the authoritative channel. + print(f"[{self.alias}] warning: broadcast send for '{cmd}' raised: {error}", file=sys.stderr) + + deadline = time.monotonic() + (timeout_ms + 60_000) / 1000 + while time.monotonic() < deadline: + try: + raw = _shell(self.serial, f"run-as {APPLICATION_ID} cat {RESULTS_DIR}/{cmd_id}.json") + if raw.strip().startswith("{"): + return json.loads(raw) + except Exception: + pass + time.sleep(1.0) + raise MeshLabError(f"[{self.alias}] timed out waiting for result of '{cmd}' ({cmd_id})") + + def cmd_ok(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict: + result = self.cmd(cmd, timeout_ms=timeout_ms, **extras) + if result.get("status") != "ok": + raise MeshLabError(f"[{self.alias}] '{cmd}' failed: {result}") + return result + + def logcat_dump(self, lines: int = 200) -> str: + return _shell(self.serial, f"logcat -d -t {lines}") + + +# MARK: - fixtures + +FIXTURE_SIZES = { + "small_1k.bin": 1_024, + "medium_512k.bin": 512 * 1_024, + "large_2m.bin": 2 * 1_024 * 1_024, +} + + +def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = None) -> dict[str, dict]: + directory.mkdir(parents=True, exist_ok=True) + fixtures = {} + rng = random.Random(seed) + for name, size in FIXTURE_SIZES.items(): + if names is not None and name not in names: + rng.randbytes(size) # keep the stream deterministic across subsets + continue + path = directory / name + data = rng.randbytes(size) + path.write_bytes(data) + fixtures[name] = {"path": path, "sha256": hashlib.sha256(data).hexdigest(), "bytes": size} + return fixtures + + +# MARK: - setup + +def setup_pair(a: Device, b: Device, apk: Path | None, nickname_a: str, nickname_b: str) -> None: + for device, nickname in ((a, nickname_a), (b, nickname_b)): + device.enable_bluetooth() + if apk is not None: + device.install(apk) + device.clear_app_data() + device.grant_permissions() + device.launch() + device.cmd_ok("start") + device.cmd_ok("set_nickname", name=nickname) + wait_for_mutual_discovery(a, b) + + +def whoami(device: Device) -> dict: + return device.cmd_ok("whoami") + + +def wait_for_peer(device: Device, peer_id: str, timeout_s: int = 90) -> dict: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + result = device.cmd_ok("peers") + for peer in result.get("peers", []): + if peer.get("id") == peer_id: + return peer + device.cmd_ok("announce") + time.sleep(3) + raise MeshLabError(f"[{device.alias}] peer {peer_id} not discovered within {timeout_s}s") + + +def wait_for_mutual_discovery(a: Device, b: Device) -> None: + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + fa = pool.submit(wait_for_peer, a, id_b) + fb = pool.submit(wait_for_peer, b, id_a) + fa.result() + fb.result() + + +# MARK: - scenarios + +def scenario_dm(a: Device, b: Device) -> dict: + """Handshake, then exchange DMs in both directions with content assertions.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + + hs = a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b) + hs_back = b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a) + + token_ab = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "dm_recv", 60_000, peer=id_a, contains=token_ab) + time.sleep(2) + send = pool.submit(a.cmd_ok, "dm_send", 30_000, peer=id_b, content=f"hello b {token_ab}") + recv_result, send_result = recv.result(), send.result() + assert token_ab in recv_result["content"], recv_result + + token_ba = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(a.cmd_ok, "dm_recv", 60_000, peer=id_b, contains=token_ba) + time.sleep(2) + send = pool.submit(b.cmd_ok, "dm_send", 30_000, peer=id_a, content=f"hello a {token_ba}") + recv_result2, send_result2 = recv.result(), send.result() + assert token_ba in recv_result2["content"], recv_result2 + + return { + "handshake_a_to_b": hs, "handshake_b_to_a": hs_back, + "a_to_b": {"send": send_result, "recv": recv_result}, + "b_to_a": {"send": send_result2, "recv": recv_result2}, + } + + +def scenario_broadcast(a: Device, b: Device) -> dict: + """Public broadcast from A received by B.""" + id_a = whoami(a)["peer_id"] + token = f"bc-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "msg_recv", 60_000, contains=token) + time.sleep(2) + send = pool.submit(a.cmd_ok, "broadcast_msg", 30_000, content=f"broadcast {token}") + recv_result, send_result = recv.result(), send.result() + assert recv_result["from"] == id_a, recv_result + return {"send": send_result, "recv": recv_result} + + +def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool = False) -> dict: + """File transfer A -> B with sha256 integrity verification.""" + id_b = whoami(b)["peer_id"] + results = {} + for name, fixture in fixtures.items(): + remote = a.push_fixture(fixture["path"]) + send_kwargs: dict[str, object] = {"path": remote} + if private: + send_kwargs["peer"] = id_b + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name) + time.sleep(2) + send = pool.submit(a.cmd_ok, "file_send", 240_000, **send_kwargs) + recv_result, send_result = recv.result(), send.result() + digest_ok = recv_result["sha256"] == fixture["sha256"] + results[name] = { + "send": send_result, "recv": recv_result, + "expected_sha256": fixture["sha256"], "digest_match": digest_ok, + } + if not digest_ok: + raise MeshLabError( + f"file '{name}' digest mismatch: {recv_result['sha256']} != {fixture['sha256']}" + ) + return results + + +def scenario_raw(a: Device, b: Device) -> dict: + """Raw packet injection (unsigned announce-type packet) reaches the mesh.""" + payload = b"meshlab-raw-" + uuid.uuid4().hex[:8].encode() + result = a.cmd_ok("raw_send", 30_000, type="05", payload_hex=payload.hex()) + return {"send": result} + + +SCENARIOS = { + "dm": scenario_dm, + "broadcast": scenario_broadcast, + "file": lambda a, b: scenario_file(a, b, make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")))), + # Private media is hard-capped at 256 fragments (PrivateMediaTransfer), so only + # the small fixture fits; larger sizes are expected to be rejected by the sender. + "file_private": lambda a, b: scenario_file( + a, b, + make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]), + private=True, + ), + "raw": scenario_raw, +} + + +def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: + started = time.time() + evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]} + try: + if name == "all": + evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in SCENARIOS} + else: + evidence["results"] = SCENARIOS[name](a, b) + evidence["status"] = "pass" + except (MeshLabError, AssertionError) as error: + evidence["status"] = "fail" + evidence["error"] = str(error) + evidence["logcat"] = {d.alias: d.logcat_dump() for d in (a, b)} + evidence["duration_s"] = round(time.time() - started, 1) + if out is not None: + out.mkdir(parents=True, exist_ok=True) + (out / f"{name}-evidence.json").write_text(json.dumps(evidence, indent=2, default=str)) + return evidence + + +# MARK: - CLI + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + + setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover") + setup.add_argument("--serial-a", required=True) + setup.add_argument("--serial-b", required=True) + setup.add_argument("--apk", type=Path, default=None) + setup.add_argument("--nickname-a", default="alice") + setup.add_argument("--nickname-b", default="bob") + + scenario = commands.add_parser("scenario", help="run a test scenario on two devices") + scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"]) + scenario.add_argument("--serial-a", required=True) + scenario.add_argument("--serial-b", required=True) + scenario.add_argument("--out", type=Path, default=None, help="evidence output directory") + + raw = commands.add_parser("cmd", help="send a raw test-hook command to one device") + raw.add_argument("--serial", required=True) + raw.add_argument("cmd") + raw.add_argument("--extra", action="append", default=[], help="key=value extra (repeatable)") + raw.add_argument("--timeout-ms", type=int, default=60_000) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "setup": + setup_pair( + Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"), + args.apk, args.nickname_a, args.nickname_b, + ) + print(json.dumps({"status": "ok", "step": "setup"})) + elif args.command == "scenario": + evidence = run_scenario( + args.name, Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"), args.out + ) + print(json.dumps(evidence, indent=2, default=str)) + return 0 if evidence["status"] == "pass" else 1 + elif args.command == "cmd": + extras: dict[str, object] = {} + for item in args.extra: + key, _, value = item.partition("=") + extras[key] = int(value) if value.isdigit() else value + result = Device(args.serial, "device").cmd(args.cmd, timeout_ms=args.timeout_ms, **extras) + print(json.dumps(result, indent=2, default=str)) + return 0 if result.get("status") == "ok" else 1 + return 0 + except MeshLabError as error: + print(f"mesh lab error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) From fbb13a33d54036f9d1521e415a64f25418000f23 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:17:35 +0200 Subject: [PATCH 2/5] Reject broadcast sends exceeding the receiver fragment cap Receivers hard-cap reassembly at MAX_FRAGMENTS_PER_ID (256), but the generic send path fragmented packets with no caller cap (0xFFFF), so broadcast file transfers above ~120 KB were fully transmitted yet undeliverable. FragmentingPacketSender now caps fragmentation at MAX_FRAGMENTS_PER_ID and reports failure via a new TransferProgressEvent.failed flag, which surfaces as DeliveryStatus.Failed in the UI and as a file_send error in the debug test hook instead of an indefinite wait. Adds FragmentingPacketSenderTest and a file_oversize mesh-lab scenario asserting sender-side rejection. --- .../android/testhook/TestHookDriver.kt | 96 ++++++++++++------- .../android/mesh/FragmentingPacketSender.kt | 25 ++++- .../android/mesh/TransferProgressManager.kt | 9 +- .../bitchat/android/ui/MediaSendingManager.kt | 11 ++- .../mesh/FragmentingPacketSenderTest.kt | 94 ++++++++++++++++++ tools/release_gate/mesh_lab.py | 25 ++++- 6 files changed, 214 insertions(+), 46 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt 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 de3f92b8..0f6675c4 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -18,6 +18,8 @@ import com.bitchat.android.services.AppStateStore import com.bitchat.android.ui.DataManager import com.bitchat.android.util.AppConstants import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -282,46 +284,70 @@ object TestHookDriver { val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet") val transferId = sha256Hex(encoded) + return coroutineScope { + // Subscribe on a background dispatcher before sending so synchronous + // failure events are not missed (SharedFlow has replay=0). + val completion = async(Dispatchers.Default) { + TransferProgressManager.events.first { it.transferId == transferId && it.completed } + } + delay(50) + val sendError = dispatchFileSend(context, intent, mesh, peerID, packet, transferId) + if (sendError != null) { + completion.cancel() + return@coroutineScope sendError.put("cmd", "file_send") + } + val event = withTimeoutOrNull(timeoutMs) { completion.await() } + ?: return@coroutineScope err("file_send", "timeout waiting for transfer completion ($transferId)") + if (event.failed) { + return@coroutineScope err("file_send", "transfer rejected/failed before send ($transferId)") + .put("transfer_id", transferId) + } + ok("file_send") + .put("transfer_id", transferId) + .put("sent", event.sent) + .put("total", event.total) + .put("bytes", content.size) + .put("peer", peerID) + } + } + + private suspend fun dispatchFileSend( + context: Context, + intent: Intent, + mesh: MeshService, + peerID: String?, + packet: BitchatFilePacket, + transferId: String + ): JSONObject? { if (peerID == null) { mesh.sendFileBroadcast(packet) - } else { - if (!mesh.hasEstablishedSession(peerID)) { - val hs = handshake(context, peerID, intent) - if (hs.optString("status") != "ok") return hs.put("cmd", "file_send") - } - // Peer state (capabilities/identity) can lag session establishment; - // retry transient preparation states before giving up. - val prepDeadline = System.currentTimeMillis() + 30_000 - while (true) { - when (val prep = mesh.prepareFilePrivate(peerID, packet, transferId, allowLegacyFallback = false)) { - is PrivateMediaPreparation.Ready -> { - if (!prep.transfer.commit()) return err("file_send", "private transfer commit failed") - break - } - PrivateMediaPreparation.AwaitingPeerState, - PrivateMediaPreparation.NeedsHandshake -> { - if (System.currentTimeMillis() >= prepDeadline) { - return err("file_send", "private media preparation stuck at: $prep") - } - if (prep == PrivateMediaPreparation.NeedsHandshake) { - mesh.initiateNoiseHandshake(peerID) - } - delay(500) - } - else -> return err("file_send", "private media preparation: $prep") + return null + } + if (!mesh.hasEstablishedSession(peerID)) { + val hs = handshake(context, peerID, intent) + if (hs.optString("status") != "ok") return hs + } + // Peer state (capabilities/identity) can lag session establishment; + // retry transient preparation states before giving up. + val prepDeadline = System.currentTimeMillis() + 30_000 + while (true) { + when (val prep = mesh.prepareFilePrivate(peerID, packet, transferId, allowLegacyFallback = false)) { + is PrivateMediaPreparation.Ready -> { + return if (prep.transfer.commit()) null else err("file_send", "private transfer commit failed") } + PrivateMediaPreparation.AwaitingPeerState, + PrivateMediaPreparation.NeedsHandshake -> { + if (System.currentTimeMillis() >= prepDeadline) { + return err("file_send", "private media preparation stuck at: $prep") + } + if (prep == PrivateMediaPreparation.NeedsHandshake) { + mesh.initiateNoiseHandshake(peerID) + } + delay(500) + } + else -> return err("file_send", "private media preparation: $prep") } } - - val event = withTimeoutOrNull(timeoutMs) { - TransferProgressManager.events.first { it.transferId == transferId && it.completed } - } ?: return err("file_send", "timeout waiting for transfer completion ($transferId)") - return ok("file_send") - .put("transfer_id", transferId) - .put("sent", event.sent) - .put("total", event.total) - .put("bytes", content.size) - .put("peer", peerID) } private suspend fun fileRecv(context: Context, intent: Intent): JSONObject { diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt index 3df51baf..9c6bd15e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -31,7 +31,13 @@ class FragmentingPacketSender( sendSingle: (RoutedPacket) -> Boolean ): Boolean { val transferId = transferIdFor(routed) - val packets = packetsForTransport(routed) ?: return false + val packets = packetsForTransport(routed) + if (packets == null) { + if (transferId != null) { + TransferProgressManager.fail(transferId) + } + return false + } val total = packets.size if (total <= 1) { @@ -45,9 +51,13 @@ class FragmentingPacketSender( preparedPackets = null ) ) - if (sent && transferId != null) { - TransferProgressManager.progress(transferId, 1, 1) - TransferProgressManager.complete(transferId, 1) + if (transferId != null) { + if (sent) { + TransferProgressManager.progress(transferId, 1, 1) + TransferProgressManager.complete(transferId, 1) + } else { + TransferProgressManager.fail(transferId) + } } return sent } @@ -125,7 +135,12 @@ class FragmentingPacketSender( val manager = fragmentManager ?: return listOf(packet) return try { - val fragments = manager.createFragments(packet) + // Receivers hard-cap reassembly at MAX_FRAGMENTS_PER_ID; sending more + // fragments would be undeliverable, so reject here instead. + val fragments = manager.createFragments( + packet, + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID + ) if (fragments.isEmpty()) { Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}") null diff --git a/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt b/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt index fbffb9aa..8dab20a2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt @@ -11,7 +11,8 @@ data class TransferProgressEvent( val transferId: String, val sent: Int, val total: Int, - val completed: Boolean + val completed: Boolean, + val failed: Boolean = false ) object TransferProgressManager { @@ -22,9 +23,9 @@ object TransferProgressManager { fun start(id: String, total: Int) { emit(id, 0, total, false) } fun progress(id: String, sent: Int, total: Int) { emit(id, sent, total, sent >= total) } fun complete(id: String, total: Int) { emit(id, total, total, true) } + fun fail(id: String) { emit(id, 0, 0, done = true, failed = true) } - private fun emit(id: String, sent: Int, total: Int, done: Boolean) { - scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done)) } + private fun emit(id: String, sent: Int, total: Int, done: Boolean, failed: Boolean = false) { + scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done, failed)) } } } - diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 68797e08..e982189f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -739,7 +739,16 @@ class MediaSendingManager( fun handleTransferProgressEvent(evt: com.bitchat.android.mesh.TransferProgressEvent) { val msgId = synchronized(transferMessageMap) { transferMessageMap[evt.transferId] } if (msgId != null) { - if (evt.completed) { + if (evt.failed) { + messageManager.updateMessageDeliveryStatus( + msgId, + com.bitchat.android.model.DeliveryStatus.Failed("transfer could not be sent") + ) + synchronized(transferMessageMap) { + val msgIdRemoved = transferMessageMap.remove(evt.transferId) + if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved) + } + } else if (evt.completed) { messageManager.updateMessageDeliveryStatus( msgId, com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date()) diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt new file mode 100644 index 00000000..c4225288 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt @@ -0,0 +1,94 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +class FragmentingPacketSenderTest { + + private val senderID = "1122334455667788" + + private fun packetWithPayload(bytes: Int): BitchatPacket { + val payload = ByteArray(bytes) + Random(42).nextBytes(payload) + return BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(senderID), + recipientID = null, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = 7u + ) + } + + @Test + fun `oversized packet exceeding receiver fragment cap is rejected with fail event`() = runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val sender = FragmentingPacketSender(scope, FragmentManager(), "test") + // ~256 * 469 bytes fit; 1 MiB clearly exceeds MAX_FRAGMENTS_PER_ID + val packet = packetWithPayload(1024 * 1024) + var sent = false + + val failed = java.util.concurrent.ConcurrentLinkedQueue() + val collectJob = launch(Dispatchers.Default) { + TransferProgressManager.events.collect { event -> + if (event.failed) failed.add(event.transferId) + } + } + kotlinx.coroutines.delay(100) // activate subscription before emitting + + val accepted = sender.send(RoutedPacket(packet, transferId = "oversize-test"), "test") { sent = true; true } + assertFalse(accepted) + assertFalse(sent) + withTimeout(5_000) { + while (!failed.contains("oversize-test")) { + kotlinx.coroutines.delay(10) + } + } + collectJob.cancel() + Unit + } + + @Test + fun `packet within fragment cap is accepted`() = runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val sender = FragmentingPacketSender(scope, FragmentManager(), "test", interFragmentDelayMs = 0L) + val packet = packetWithPayload(10_000) + var writes = 0 + + val accepted = sender.send(RoutedPacket(packet, transferId = "fits-test"), "test") { writes += 1; true } + assertTrue(accepted) + withTimeout(5_000) { + while (writes == 0) { + kotlinx.coroutines.delay(10) + } + } + assertTrue(writes > 0) + } + + @Test + fun `fragment count at cap boundary is not rejected`() { + val manager = FragmentManager() + val packet = packetWithPayload(AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID * 400) + val fragments = manager.createFragments(packet, AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) + assertTrue(fragments.isNotEmpty()) + assertTrue(fragments.size <= AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) + } +} diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index d1afe470..32aebd67 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -326,10 +326,33 @@ def scenario_raw(a: Device, b: Device) -> dict: return {"send": result} +def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> dict: + """Oversized broadcast file must be rejected sender-side (>256 fragments).""" + fixture = fixtures["medium_512k.bin"] + remote = a.push_fixture(fixture["path"]) + send = a.cmd("file_send", timeout_ms=60_000, path=remote) + rejected = send.get("status") == "error" and "rejected" in send.get("error", "") + if not rejected: + raise MeshLabError(f"expected sender-side rejection, got: {send}") + # Receiver must not see any file appear. + recv = b.cmd("file_recv", timeout_ms=15_000, name_contains="medium_512k") + if recv.get("status") == "ok": + raise MeshLabError(f"receiver unexpectedly saved an oversized file: {recv}") + return {"send": send, "receiver_saw_file": False} + + SCENARIOS = { "dm": scenario_dm, "broadcast": scenario_broadcast, - "file": lambda a, b: scenario_file(a, b, make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")))), + # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only + # the small fixture is end-to-end receivable. + "file": lambda a, b: scenario_file( + a, b, + make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]), + ), + "file_oversize": lambda a, b: scenario_file_oversize( + a, b, make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-"))) + ), # Private media is hard-capped at 256 fragments (PrivateMediaTransfer), so only # the small fixture fits; larger sizes are expected to be rejected by the sender. "file_private": lambda a, b: scenario_file( From 3eb9ac20b0cfc2a8441ee963e6a55515c77da129 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:01:21 +0200 Subject: [PATCH 3/5] mesh_lab: clear receiver incoming dirs before file scenarios Incoming files are name-uniquified on receipt (e.g. 'small_1k (1).bin'), so repeated runs broke the name-based file_recv matcher. --- tools/release_gate/mesh_lab.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 32aebd67..6d35850e 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -130,6 +130,12 @@ class Device: ) return target + def clear_incoming(self) -> None: + _shell( + self.serial, + f"run-as {APPLICATION_ID} rm -rf cache/files/incoming cache/images/incoming", + ) + # -- test hook commands ------------------------------------------------- def cmd(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict: @@ -296,6 +302,7 @@ def scenario_broadcast(a: Device, b: Device) -> dict: def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool = False) -> dict: """File transfer A -> B with sha256 integrity verification.""" id_b = whoami(b)["peer_id"] + b.clear_incoming() # avoid name-uniquified collisions across runs results = {} for name, fixture in fixtures.items(): remote = a.push_fixture(fixture["path"]) From 8827a07b114b1ba960927b1b433aab1cc9bc4a4c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:18:31 +0200 Subject: [PATCH 4/5] mesh_lab: add session_recovery and identity_reset churn scenarios - session_recovery: force-stop B mid-session; assert identity persists, mesh rejoins, re-handshake completes, DMs flow again - identity_reset: pm clear B mid-session; assert new identity, rediscovery, handshake and DM round trip - Helpers: wake() (foreground the app so BLE leaves POWER_SAVER), reset_bluetooth() (clear zombie GATT links), ensure_direct_link() (explicit GATT connect instead of waiting out duty cycles), force_handshake() with retries --- tools/release_gate/mesh_lab.py | 188 +++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 6d35850e..821ce844 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -94,10 +94,39 @@ class Device: if "Success" not in output: raise MeshLabError(f"[{self.alias}] pm clear failed: {output}") + def force_stop(self) -> None: + _shell(self.serial, f"am force-stop {APPLICATION_ID}") + def launch(self) -> None: _shell(self.serial, f"monkey -p {APPLICATION_ID} -c android.intent.category.LAUNCHER 1") time.sleep(3) + def wake(self) -> None: + """Keep the screen on and the app foregrounded (full-power BLE duty cycle). + + A backgrounded app drops to POWER_SAVER (1 s scan per 60 s), which makes + mesh reformation after restarts take minutes and scenarios flaky. + `svc power stayon` only applies while charging, so also stretch the + screen timeout as a fallback. + """ + _shell(self.serial, "svc power stayon true") + _shell(self.serial, "settings put system screen_off_timeout 600000") + subprocess.run( + [find_adb(), "-s", self.serial, "shell", "locksettings", "set-disabled", "true"], + check=False, capture_output=True, text=True, timeout=30, + ) + _shell(self.serial, "input keyevent KEYCODE_WAKEUP") + _shell(self.serial, "wm dismiss-keyguard") + _shell(self.serial, "input keyevent 82") # dismiss non-secure keyguard + _shell(self.serial, "input swipe 500 1500 500 400") # swipe-up dismiss + + def reset_bluetooth(self) -> None: + """Cycle the BT adapter; clears zombie GATT connections from peer restarts.""" + _shell(self.serial, "svc bluetooth disable") + time.sleep(2) + _shell(self.serial, "svc bluetooth enable") + time.sleep(3) + def enable_bluetooth(self) -> None: subprocess.run( [find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"], @@ -216,11 +245,13 @@ def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = N def setup_pair(a: Device, b: Device, apk: Path | None, nickname_a: str, nickname_b: str) -> None: for device, nickname in ((a, nickname_a), (b, nickname_b)): + device.reset_bluetooth() device.enable_bluetooth() if apk is not None: device.install(apk) device.clear_app_data() device.grant_permissions() + device.wake() device.launch() device.cmd_ok("start") device.cmd_ok("set_nickname", name=nickname) @@ -333,6 +364,161 @@ def scenario_raw(a: Device, b: Device) -> dict: return {"send": result} +# MARK: - session / identity churn scenarios + +def _dm_roundtrip(a: Device, b: Device, id_a: str, id_b: str) -> dict: + """Exchange DMs in both directions with content assertions.""" + token_ab = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "dm_recv", 60_000, peer=id_a, contains=token_ab) + time.sleep(2) + send = pool.submit(a.cmd_ok, "dm_send", 30_000, peer=id_b, content=f"hello b {token_ab}") + recv_ab, send_ab = recv.result(), send.result() + assert token_ab in recv_ab["content"], recv_ab + + token_ba = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(a.cmd_ok, "dm_recv", 60_000, peer=id_b, contains=token_ba) + time.sleep(2) + send = pool.submit(b.cmd_ok, "dm_send", 30_000, peer=id_a, content=f"hello a {token_ba}") + recv_ba, send_ba = recv.result(), send.result() + assert token_ba in recv_ba["content"], recv_ba + return {"a_to_b": recv_ab, "b_to_a": recv_ba} + + +def wait_session_established(device: Device, peer_id: str, timeout_s: int = 90) -> dict: + deadline = time.monotonic() + timeout_s + last: dict = {} + while time.monotonic() < deadline: + last = device.cmd_ok("session", peer=peer_id) + if last.get("established"): + return last + time.sleep(2) + raise MeshLabError( + f"[{device.alias}] session with {peer_id} not established within {timeout_s}s (last: {last})" + ) + + +def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None: + """Wait for rediscovery, then force a direct GATT connection both ways. + + Backgrounded devices drop to POWER_SAVER duty cycles (1 s scan per 60 s), so + passively waiting for the mesh to reform takes minutes. The explicit connect + makes restart scenarios deterministic. + """ + wait_for_peer(a, id_b, timeout_s=120) + wait_for_peer(b, id_a, timeout_s=120) + for device, peer in ((a, id_b), (b, id_a)): + result = device.cmd("connect", timeout_ms=45_000, peer=peer) + if result.get("status") == "ok" and result.get("direct"): + continue + # Already acceptable if the mesh formed a direct link on its own. + peers = device.cmd_ok("peers").get("peers", []) + match = next((p for p in peers if p.get("id") == peer), None) + if not match or not match.get("direct"): + raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={result}") + + +def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict: + """Retry explicit handshakes; inits can be lost while links settle.""" + last: dict = {} + for _ in range(attempts): + last = device.cmd("handshake", timeout_ms=per_attempt_s * 1000, peer=peer_id) + if last.get("status") == "ok": + return last + time.sleep(2) + raise MeshLabError(f"[{device.alias}] handshake with {peer_id} failed after {attempts} attempts (last: {last})") + + +def scenario_session_recovery(a: Device, b: Device) -> dict: + """Process death on B: identity must persist, in-memory Noise sessions are lost. + + Expected recovery flow: A's DM sent with its stale session is dropped by B + (B has no session and no kick path on pure decrypt failure); B's outgoing DM + auto-triggers a fresh handshake; subsequent DMs must flow both ways. + """ + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + a.cmd_ok("handshake", 60_000, peer=id_b) + b.cmd_ok("handshake", 60_000, peer=id_a) + baseline = _dm_roundtrip(a, b, id_a, id_b) + + b.force_stop() + b.wake() + b.launch() + b.cmd_ok("start") + b.cmd_ok("set_nickname", name="bob") + id_b_after = whoami(b)["peer_id"] + if id_b_after != id_b: + raise MeshLabError(f"identity changed across process death: {id_b} -> {id_b_after}") + + wait_for_peer(a, id_b, timeout_s=120) + wait_for_peer(b, id_a, timeout_s=120) + ensure_direct_link(a, b, id_a, id_b) + + # A -> B with A's stale session: B lost its in-memory session; drop expected. + a.cmd_ok("dm_send", 30_000, peer=id_b, content=f"stale-{uuid.uuid4().hex[:8]}") + # B -> A: no session on B, sendPrivateMessage auto-fires the re-handshake. + # The fire-and-forget handshake has no retry, so repeat the trigger, then + # fall back to explicit handshake commands if the auto-path stays stuck. + session_b: dict = {} + for _attempt in range(3): + b.cmd_ok("dm_send", 30_000, peer=id_a, content=f"trigger-{uuid.uuid4().hex[:8]}") + try: + session_b = wait_session_established(b, id_a, timeout_s=20) + break + except MeshLabError: + continue + if not session_b: + force_handshake(b, id_a) + session_b = wait_session_established(b, id_a, timeout_s=30) + + session_a = wait_session_established(a, id_b) + recovered = _dm_roundtrip(a, b, id_a, id_b) + return { + "identity_preserved": True, + "baseline": baseline, + "session_a": session_a, + "session_b": session_b, + "recovered": recovered, + } + + +def scenario_identity_reset(a: Device, b: Device) -> dict: + """pm clear on B mid-session: new identity, rediscovery, fresh handshake and DMs.""" + id_a = whoami(a)["peer_id"] + id_b_old = whoami(b)["peer_id"] + a.cmd_ok("handshake", 60_000, peer=id_b_old) + b.cmd_ok("handshake", 60_000, peer=id_a) + _dm_roundtrip(a, b, id_a, id_b_old) + + b.clear_app_data() + b.grant_permissions() + b.wake() + b.launch() + b.cmd_ok("start") + b.cmd_ok("set_nickname", name="bob") + id_b_new = whoami(b)["peer_id"] + if id_b_new == id_b_old: + raise MeshLabError("identity survived pm clear") + + wait_for_peer(a, id_b_new, timeout_s=180) + ensure_direct_link(a, b, id_a, id_b_new) + force_handshake(a, id_b_new) + force_handshake(b, id_a) + recovered = _dm_roundtrip(a, b, id_a, id_b_new) + + # Inspect how A treats the dead peer's stale session (evidence, not an assertion). + stale = a.cmd("session", peer=id_b_old) + return { + "old_peer_id": id_b_old, + "new_peer_id": id_b_new, + "identity_changed": True, + "recovered": recovered, + "stale_session_on_a": stale, + } + + def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> dict: """Oversized broadcast file must be rejected sender-side (>256 fragments).""" fixture = fixtures["medium_512k.bin"] @@ -368,6 +554,8 @@ SCENARIOS = { private=True, ), "raw": scenario_raw, + "session_recovery": scenario_session_recovery, + "identity_reset": scenario_identity_reset, } From 11f39c27f4452694a74a5dc2c2b9a6ef95f1a1fb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:24:03 +0200 Subject: [PATCH 5/5] docs: complete mesh lab guide (prereqs, device prep, scenarios, troubleshooting) Expands the release-gate runbook appendix into a fresh-clone walkthrough and points AGENTS.md at the ADB device mesh tests, noting they are deliberately separate from Gradle/CI. --- AGENTS.md | 5 ++ docs/release-gate-runbook.md | 97 ++++++++++++++++++++++++++++++------ 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5244db5f..edc167b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,11 @@ The application follows a clean architecture pattern, heavily modularized by fea ### Testing - **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing. - **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing. +- **Device Mesh Tests (ADB test hooks)**: Two-physical-device scenarios driven over ADB, **kept separate from Gradle/CI** — run them manually when changing mesh/crypto/transfer code. A debug-only broadcast receiver (`app/src/debug/java/com/bitchat/android/testhook/`, never in release builds) exposes mesh operations (scan, connect, Noise handshake, DMs, broadcast, files, raw packet injection) via `am broadcast -a com.bitchat.droid.TEST_HOOK`; the host orchestrator is `tools/release_gate/mesh_lab.py`. Full guide: `docs/release-gate-runbook.md` appendix "mesh lab". + - Prereqs: `adb` on PATH, Python 3.10+, two devices with USB debugging, **both unlocked with screen on** (locked/dozing → POWER_SAVER → flaky timing). + - Setup: `./gradlew assembleDebug && python3 tools/release_gate/mesh_lab.py setup --serial-a --serial-b --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk` + - Run: `python3 tools/release_gate/mesh_lab.py scenario all --serial-a --serial-b --out /tmp/meshlab-evidence` + - Scenarios: `dm`, `broadcast`, `file`, `file_oversize`, `file_private`, `raw`, `session_recovery`, `identity_reset`, `all`. Ad-hoc: `... cmd --serial state`. - **Execution**: - Unit: `./gradlew test` - Instrumented: `./gradlew connectedAndroidTest` diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 90e58deb..9ac18f30 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -252,22 +252,91 @@ public broadcast, announce, file send/receive, BLE toggle, state dumps, and raw packet injection. Results are JSON files in the app sandbox polled by the host (`cache/testhook/results/.json`, also logged under tag `TestHook`). +### Prerequisites + +- A JDK (e.g. the one bundled with Android Studio; set `JAVA_HOME`) and the + Android SDK platform-tools. `adb` must be on `PATH` or `ANDROID_HOME` set. +- Python 3.10+ on the host. No third-party packages are required. +- **Two physical Android devices** (API 26+, BLE) with USB debugging enabled, + both plugged into the host. Emulators are not supported (BLE mesh). +- Verify both are visible: `adb devices` → note the serials. + +### Device preparation (important) + +Keep both phones **unlocked with the screen on** for the whole run. A locked +or dozing device forces the app into POWER_SAVER (1 s BLE scan per 60 s), +which makes discovery and handshakes take minutes and will flake every +scenario. The harness runs `wake()` (dismiss keyguard, stretch screen +timeout) during `setup`, but it cannot defeat a secure lock screen — unlock +the devices manually first. Note that `svc power stayon` only helps while a +device is actually charging. + +### Build and set up + ```sh -# install + grant + launch + nickname + mutual discovery on two devices +./gradlew assembleDebug python3 tools/release_gate/mesh_lab.py setup \ - --serial-a --serial-b \ + --serial-a --serial-b \ --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk - -# scenarios: dm, broadcast, file, file_private, raw, all -python3 tools/release_gate/mesh_lab.py scenario file \ - --serial-a --serial-b --out /tmp/meshlab-evidence - -# single command against one device -python3 tools/release_gate/mesh_lab.py cmd --serial scan \ - --extra timeout_ms=30000 ``` -The file scenarios push deterministic fixtures into the app sandbox and verify -the receiver's saved file by SHA-256. Unlike the release gate, this harness is -a development aid: it prints raw diagnostics and does not produce a -privacy-checked approval bundle. +`setup` cycles Bluetooth, installs the APK, clears app data, grants all +runtime permissions, wakes and launches the app, sets deterministic nicknames +(`alice`/`bob`), and waits for mutual peer discovery. It is safe (and +recommended) to rerun `setup` before each scenario batch; `--apk` may be +omitted if the current build is already installed. + +### Run scenarios + +```sh +python3 tools/release_gate/mesh_lab.py scenario all \ + --serial-a --serial-b --out /tmp/meshlab-evidence +``` + +| Scenario | What it asserts | +|---|---| +| `dm` | Noise handshake both ways, encrypted DM round trips with content match | +| `broadcast` | public mesh message A→B | +| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | +| `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing | +| `file_private` | Noise-encrypted private file, digest match | +| `raw` | raw packet injection is accepted by the mesh | +| `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again | +| `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs | +| `all` | every scenario above in sequence | + +Each run writes `-evidence.json` to `--out` (digests, timings, +session states, logcat excerpts on failure) and exits non-zero on failure. +Evidence is a local diagnostic artifact; it may contain lab peer IDs and is +not privacy-checked like release-gate bundles — do not publish it. + +### Ad-hoc commands + +Any hook command can be sent to one device directly: + +```sh +python3 tools/release_gate/mesh_lab.py cmd --serial scan --extra timeout_ms=30000 +python3 tools/release_gate/mesh_lab.py cmd --serial handshake --extra peer= +python3 tools/release_gate/mesh_lab.py cmd --serial state # full mesh dump +``` + +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`, `file_send`, +`file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`). + +### Troubleshooting + +- **Discovery/handshake timeouts**: almost always a locked or dozing phone — + unlock both devices and rerun `setup`. `cmd ... state` shows + `App In Background: true` and the BLE duty cycle when this is the cause. +- **Stale app state after many churn runs**: `svc bluetooth disable/enable` + on both devices (done automatically by `setup`) clears zombie GATT links. +- **Watch the wire**: `adb -s logcat -s TestHook MessageHandler + FragmentManager BitchatFilePacket` shows commands, results, decrypt + failures, fragment rejects, and saved incoming files in real time. +- Results also persist on-device at + `run-as com.bitchat.droid cat cache/testhook/results/.json`. + +Unlike the release gate, this harness is a development aid: it prints raw +diagnostics and does not produce a privacy-checked approval bundle.