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())