2026-07-31 17:40:37 +02:00

734 lines
32 KiB
Kotlin

package com.bitchat.android.testhook
import android.content.Context
import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.features.voice.LiveVoiceEvent
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceScope
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.LiveVoiceCapture
import com.bitchat.android.identity.SecureIdentityStateManager
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.ui.PrivateMediaRecipientResolver
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
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)
"favorite_set" -> favoriteSet(
context,
intent.requiredString("peer"),
intent.getBooleanExtra("enabled", true)
)
"favorite_status" -> favoriteStatus(context, intent.requiredString("peer"))
"verification_set" -> verificationSet(
context,
intent.requiredString("peer"),
intent.getBooleanExtra("enabled", true)
)
"verification_status" -> verificationStatus(context, intent.requiredString("peer"))
"file_send" -> fileSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
"ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(context, intent)
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"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))
}
/**
* Debug-only state injection for testing peer-list consumers such as notifications.
* A blank or missing comma-separated value restores the empty state.
*/
private fun injectPeers(commaSeparatedPeers: String?): JSONObject {
val peers = commaSeparatedPeers
.orEmpty()
.split(',')
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
AppStateStore.setPeers(peers)
return ok("inject_peers").put("peers", JSONArray(peers))
}
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: - Favorite and verification state
private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
val mesh = mesh(context)
val peerInfo = mesh.getPeerInfo(peerID)
?: return err("favorite_set", "peer is not known")
val noisePublicKey = peerInfo.noisePublicKey
?: return err("favorite_set", "peer Noise key is unavailable")
FavoritesPersistenceService.initialize(context)
FavoritesPersistenceService.shared.updateFavoriteStatus(
noisePublicKey = noisePublicKey,
nickname = peerInfo.nickname,
isFavorite = enabled
)
mesh.sendFavoriteNotification(peerID, enabled)
return favoriteStatus(context, peerID)
}
private fun favoriteStatus(context: Context, peerID: String): JSONObject {
val mesh = mesh(context)
FavoritesPersistenceService.initialize(context)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
?: mesh.getPeerInfo(peerID)?.noisePublicKey?.let {
FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
val isFavorite = relationship?.isFavorite == true
val theyFavoritedUs = relationship?.theyFavoritedUs == true
return ok("favorite_status")
.put("peer", peerID)
.put("is_favorite", isFavorite)
.put("they_favorited_us", theyFavoritedUs)
.put("is_mutual", isFavorite && theyFavoritedUs)
.put(
"star_state",
when {
isFavorite -> "filled"
theyFavoritedUs -> "outlined_orange"
else -> "outlined"
}
)
}
private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
val mesh = mesh(context)
val fingerprint = mesh.getPeerFingerprint(peerID)
?: return err("verification_set", "peer fingerprint is unavailable")
SecureIdentityStateManager(context).setVerifiedFingerprint(fingerprint, enabled)
return verificationStatus(context, peerID)
}
private fun verificationStatus(context: Context, peerID: String): JSONObject {
val fingerprint = mesh(context).getPeerFingerprint(peerID)
val verified = fingerprint != null &&
SecureIdentityStateManager(context).getVerifiedFingerprints().any {
it.equals(fingerprint, ignoreCase = true)
}
return ok("verification_status")
.put("peer", peerID)
.put("fingerprint", fingerprint ?: JSONObject.NULL)
.put("verified", verified)
}
// 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)
val recipient = peerID?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)
?: return err("file_send", "no active mesh route for private conversation: $it")
}
return coroutineScope {
// Subscribe on a background dispatcher before sending so synchronous
// failure events are not missed (SharedFlow has replay=0).
val completion = async(Dispatchers.Default) {
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
}
delay(50)
val sendError = dispatchFileSend(
context,
intent,
mesh,
recipient?.meshPeerID,
packet,
transferId
)
if (sendError != null) {
completion.cancel()
return@coroutineScope sendError.put("cmd", "file_send")
}
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
?: return@coroutineScope err("file_send", "timeout waiting for transfer completion ($transferId)")
if (event.failed) {
return@coroutineScope err("file_send", "transfer rejected/failed before send ($transferId)")
.put("transfer_id", transferId)
}
ok("file_send")
.put("transfer_id", transferId)
.put("sent", event.sent)
.put("total", event.total)
.put("bytes", content.size)
.put("peer", recipient?.meshPeerID)
.put("conversation", peerID)
}
}
private suspend fun dispatchFileSend(
context: Context,
intent: Intent,
mesh: MeshService,
peerID: String?,
packet: BitchatFilePacket,
transferId: String
): JSONObject? {
if (peerID == null) {
mesh.sendFileBroadcast(packet)
return null
}
if (!mesh.hasEstablishedSession(peerID)) {
val hs = handshake(context, peerID, intent)
if (hs.optString("status") != "ok") return hs
}
// 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 -> {
return if (prep.transfer.commit()) null else err("file_send", "private transfer commit failed")
}
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")
}
}
}
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: - Live push-to-talk
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
val requestedPeer = intent.getStringExtra("peer")
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val mesh = mesh(context)
LiveVoicePreferences.setEnabled(context, true)
val recipient = requestedPeer?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)
?: return err("ptt_send", "no active mesh route for private conversation")
}
if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) {
val handshake = handshake(context, recipient.meshPeerID, intent)
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
}
val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) }
val recorder = LiveVoiceCapture(
File(context.filesDir, "voicenotes/outgoing"),
target,
syntheticPcm = true
)
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
delay(durationMs)
val finalFile = recorder.stop(canceled = false)
?: return err("ptt_send", "capture did not produce a finalized note")
val captureStats = recorder.stats()
if (finalFile != pendingFile || !finalFile.isFile) {
return err("ptt_send", "finalized note is unavailable")
}
val content = withContext(Dispatchers.IO) { finalFile.readBytes() }
val packet = BitchatFilePacket(
fileName = finalFile.name,
fileSize = content.size.toLong(),
mimeType = "audio/mp4",
content = content
)
val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note")
val transferId = sha256Hex(encoded)
return coroutineScope {
val completion = async(Dispatchers.Default) {
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
}
delay(50)
val sendError = dispatchFileSend(
context,
intent,
mesh,
recipient?.meshPeerID,
packet,
transferId
)
if (sendError != null) {
completion.cancel()
return@coroutineScope sendError.put("cmd", "ptt_send")
}
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer")
if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed")
ok("ptt_send")
.put("live", true)
.put("scope", if (recipient == null) "public" else "dm")
.put("duration_ms", durationMs)
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
.put("bytes", content.size)
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
.put("encoded_frames", captureStats.encodedFrames)
.put("data_packets", captureStats.dataPackets)
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
.put("outbound_packets", captureStats.outboundPackets)
.put("delivered_packets", captureStats.deliveredPackets)
}
}
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val expectedScope = if (intent.getStringExtra("scope") == "public") {
LiveVoiceScope.PUBLIC_MESH
} else {
LiveVoiceScope.DIRECT_MESSAGE
}
LiveVoicePreferences.setEnabled(context, true)
var finished: LiveVoiceEvent.Finished? = null
var liveSnapshot: File? = null
val absorbed = withTimeoutOrNull(timeoutMs) {
LiveVoiceManager.getInstance(context).events.first { event ->
val matches = event.scope == expectedScope &&
(fromPeer == null || event.peerID == fromPeer)
if (matches && event is LiveVoiceEvent.Finished) {
finished = event
liveSnapshot = runCatching {
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
snapshot.parentFile?.mkdirs()
File(event.path).copyTo(snapshot, overwrite = true)
}
}.getOrNull()
}
matches && event is LiveVoiceEvent.Absorbed
} as LiveVoiceEvent.Absorbed
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
val analysis = liveSnapshot?.let { snapshot ->
try {
withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) }
} finally {
snapshot.delete()
}
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
return ok("ptt_recv")
.put("live_observed", finished != null)
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
.put("from", absorbed.peerID)
.put("burst_id", absorbed.burstID)
.put("frames", finished?.frames ?: 0)
.put("data_packets", finished?.dataPackets ?: 0)
.put("bytes", finished?.bytes ?: 0)
.put("expected_packets", finished?.expectedPackets ?: 0)
.put("missing_packets", finished?.missingPackets ?: 0)
.put("decoded_samples", analysis.decodedSamples)
.put("rms", analysis.rms)
.put("silent_block_fraction", analysis.silentBlockFraction)
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
}
// 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
}
}
}