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.
This commit is contained in:
callebtc 2026-07-28 13:05:49 +02:00
parent 96340a35aa
commit 41494d7c16
5 changed files with 1003 additions and 0 deletions

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- Debug-only ADB test hook. Drives mesh operations (scan, connect,
handshake, DMs, files, broadcast, raw packets) from host scripts.
Exported intentionally so `adb shell am broadcast` can reach it;
never shipped in release builds. -->
<receiver
android:name="com.bitchat.android.testhook.TestHookReceiver"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.bitchat.droid.TEST_HOOK" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -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<String>): 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
}
}
}

View File

@ -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 <command> --es id <cmd-id> [command extras...]
*
* Result is written to cache/testhook/results/<id>.json and logged under tag TestHook:
* adb shell run-as com.bitchat.droid cat cache/testhook/results/<id>.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()
}
}

View File

@ -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/<id>.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 <ephemeral> --serial-b <ephemeral> \
--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 <ephemeral> --serial-b <ephemeral> --out /tmp/meshlab-evidence
# single command against one device
python3 tools/release_gate/mesh_lab.py cmd --serial <ephemeral> 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.

View File

@ -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/<id>.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())