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 } } }