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