mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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.
470 lines
20 KiB
Kotlin
470 lines
20 KiB
Kotlin
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
|
|
}
|
|
}
|
|
}
|