Merge remote-tracking branch 'origin/main' into codex/reproducible-builds

This commit is contained in:
callebtc 2026-07-29 12:08:12 +02:00
commit dcb77dc6b1
77 changed files with 7020 additions and 135 deletions

View File

@ -56,6 +56,11 @@ The application follows a clean architecture pattern, heavily modularized by fea
### Testing
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
- **Device Mesh Tests (ADB test hooks)**: Two-physical-device scenarios driven over ADB, **kept separate from Gradle/CI** — run them manually when changing mesh/crypto/transfer code. A debug-only broadcast receiver (`app/src/debug/java/com/bitchat/android/testhook/`, never in release builds) exposes mesh operations (scan, connect, Noise handshake, DMs, broadcast, files, raw packet injection) via `am broadcast -a com.bitchat.droid.TEST_HOOK`; the host orchestrator is `tools/release_gate/mesh_lab.py`. Full guide: `docs/release-gate-runbook.md` appendix "mesh lab".
- Prereqs: `adb` on PATH, Python 3.10+, two devices with USB debugging, **both unlocked with screen on** (locked/dozing → POWER_SAVER → flaky timing).
- Setup: `./gradlew assembleDebug && python3 tools/release_gate/mesh_lab.py setup --serial-a <s1> --serial-b <s2> --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk`
- Run: `python3 tools/release_gate/mesh_lab.py scenario all --serial-a <s1> --serial-b <s2> --out /tmp/meshlab-evidence`
- Scenarios: `dm`, `broadcast`, `file`, `file_oversize`, `file_private`, `raw`, `session_recovery`, `identity_reset`, `all`. Ad-hoc: `... cmd --serial <s> state`.
- **Execution**:
- Unit: `./gradlew test`
- Instrumented: `./gradlew connectedAndroidTest`

View File

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

View File

@ -0,0 +1,508 @@
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.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)
"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)
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: - Raw packet injection
private fun rawSend(context: Context, intent: Intent): JSONObject {
val payloadHex = intent.requiredString("payload_hex")
val typeStr = intent.requiredString("type")
val peerID = intent.getStringExtra("peer")
val ttl = intent.getIntExtra("ttl", 7)
val type = typeStr.toUIntOrNull(16)?.toUByte()
?: return err("raw_send", "invalid type hex: $typeStr")
val payload = hexToBytes(payloadHex)
?: return err("raw_send", "invalid payload_hex")
val mesh = mesh(context)
val packet = BitchatPacket(
type = type,
ttl = ttl.toUByte(),
senderID = mesh.myPeerID,
payload = payload
)
if (peerID != null) {
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
} else {
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}
return ok("raw_send")
.put("type", typeStr)
.put("payload_bytes", payload.size)
.put("peer", peerID)
}
// MARK: - Transport / state
private fun setBle(enabled: Boolean): JSONObject {
val ble = MeshServiceHolder.meshService ?: return err("ble", "BLE service not running")
ble.setBleTransportEnabled(enabled)
return ok("ble").put("enabled", enabled)
}
private fun state(context: Context): JSONObject {
val mesh = mesh(context)
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
val sessions = JSONObject()
AppStateStore.peers.value.forEach { peerID ->
sessions.put(peerID, mesh.getSessionState(peerID).toString())
}
return ok("state")
.put("peer_id", mesh.myPeerID)
.put("nickname", AppStateStore.nickname.value)
.put("peers", peersJson)
.put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList()))
.put("sessions", sessions)
.put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>))
.put("debug_status", mesh.getDebugStatus())
}
private fun clearResults(context: Context): JSONObject {
val dir = File(context.cacheDir, "testhook/results")
val count = dir.listFiles()?.count { it.delete() } ?: 0
return ok("clear_results").put("deleted", count)
}
// MARK: - Helpers
private fun mesh(context: Context): MeshService = MeshServiceHolder.getUnifiedOrCreate(context)
private fun peerInfosJson(mesh: MeshService, peerIds: List<String>): JSONArray {
val nicknames = mesh.getPeerNicknames()
val rssi = mesh.getPeerRSSI()
val arr = JSONArray()
peerIds.forEach { id ->
val info = mesh.getPeerInfo(id)
arr.put(JSONObject()
.put("id", id)
.put("nickname", nicknames[id] ?: info?.nickname)
.put("rssi", rssi[id])
.put("direct", AppStateStore.directPeers.value.contains(id))
.put("connected", info?.isConnected)
.put("last_seen", info?.lastSeen)
.put("session", mesh.getSessionState(id).toString())
.put("fingerprint", mesh.getPeerFingerprint(id)))
}
return arr
}
private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd)
private fun err(cmd: String, message: String) =
JSONObject().put("status", "error").put("cmd", cmd).put("error", message)
private fun Intent.requiredString(name: String): String =
getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name")
private fun sha256Hex(data: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(data).toHex()
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun hexToBytes(hex: String): ByteArray? {
val clean = hex.replace(" ", "")
if (clean.length % 2 != 0) return null
return try {
ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
} catch (e: Exception) {
null
}
}
}

View File

@ -0,0 +1,65 @@
package com.bitchat.android.testhook
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.io.File
/**
* ADB-drivable test hook (debug builds only).
*
* Usage:
* adb shell am broadcast -a com.bitchat.droid.TEST_HOOK \
* --es cmd <command> --es id <cmd-id> [command extras...]
*
* Result is written to cache/testhook/results/<id>.json and logged under tag TestHook:
* adb shell run-as com.bitchat.droid cat cache/testhook/results/<id>.json
*/
class TestHookReceiver : BroadcastReceiver() {
companion object {
const val TAG = "TestHook"
const val ACTION = "com.bitchat.droid.TEST_HOOK"
private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return
val cmd = intent.getStringExtra("cmd") ?: "ping"
val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}"
val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS)
Log.i(TAG, "CMD id=$id cmd=$cmd")
val pendingResult = goAsync()
Thread {
val result = try {
runBlocking {
withTimeout(overallTimeout) {
TestHookDriver.execute(context.applicationContext, cmd, intent)
}
}
} catch (e: Exception) {
JSONObject()
.put("status", "error")
.put("cmd", cmd)
.put("error", "${e.javaClass.simpleName}: ${e.message}")
}
try {
val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() }
File(dir, "$id.json").writeText(result.toString())
} catch (e: Exception) {
Log.e(TAG, "Failed to write result file for $id: ${e.message}")
}
Log.i(TAG, "RESULT id=$id $result")
}.start()
// Finish immediately: long-running commands continue on the worker thread and
// report via the result file. Holding the broadcast open past the system
// broadcast window would ANR the app.
pendingResult.finish()
}
}

View File

@ -1,6 +1,5 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
@ -39,9 +38,7 @@ import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.util.UniversalApkManager
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import java.io.File
/**
@ -158,18 +155,10 @@ fun HotspotScreen(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun IntroScreen(onStartHotspot: () -> Unit) {
// Determine which permission to request based on Android version
val requiredPermission = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.NEARBY_WIFI_DEVICES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Manifest.permission.ACCESS_FINE_LOCATION
else -> null // No runtime permission needed on Android < 10
}
val permissionState = requiredPermission?.let {
rememberPermissionState(it) { granted ->
if (granted) {
onStartHotspot()
}
val requiredPermissions = remember { HotspotPermissions.requiredForSdk() }
val permissionState = rememberMultiplePermissionsState(requiredPermissions) { results ->
if (requiredPermissions.all { results[it] == true }) {
onStartHotspot()
}
}
@ -219,7 +208,7 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
}
// Permission rationale (if needed)
if (permissionState != null && !permissionState.status.isGranted && permissionState.status.shouldShowRationale) {
if (!permissionState.allPermissionsGranted && permissionState.shouldShowRationale) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@ -237,10 +226,13 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
"BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
} else {
"BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
text = when {
Build.VERSION.SDK_INT >= HotspotPermissions.ANDROID_17_API_LEVEL ->
"BitChat needs nearby devices and local network access to create a Wi-Fi hotspot and serve the app to connected devices."
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
"BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
else ->
"BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
@ -278,12 +270,12 @@ fun IntroScreen(onStartHotspot: () -> Unit) {
Button(
onClick = {
// Check permission before starting hotspot
if (permissionState == null || permissionState.status.isGranted) {
if (permissionState.allPermissionsGranted) {
// No permission needed or already granted
onStartHotspot()
} else {
// Request permission (auto-start handled by onPermissionResult callback)
permissionState.launchPermissionRequest()
permissionState.launchMultiplePermissionRequest()
}
},
modifier = Modifier

View File

@ -6,7 +6,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pManager
@ -16,7 +15,6 @@ import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import java.net.NetworkInterface
import java.security.SecureRandom
import kotlin.random.Random
@ -100,12 +98,15 @@ class HotspotManager(private val context: Context) {
return
}
val missingPermission = requiredRuntimePermission()?.takeUnless {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (missingPermission != null) {
Log.w(TAG, "Cannot start hotspot without $missingPermission")
callback.onError("Nearby Wi-Fi permission is required to start the hotspot")
val missingPermissions = HotspotPermissions.missingFrom(context)
if (missingPermissions.isNotEmpty()) {
Log.w(TAG, "Cannot start hotspot; missing required permissions: $missingPermissions")
val message = if (Manifest.permission.ACCESS_LOCAL_NETWORK in missingPermissions) {
"Local network permission is required to share the app over the hotspot"
} else {
"Nearby Wi-Fi permission is required to start the hotspot"
}
callback.onError(message)
return
}
@ -248,7 +249,7 @@ class HotspotManager(private val context: Context) {
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
@ -369,17 +370,7 @@ class HotspotManager(private val context: Context) {
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
}
}
private fun requiredRuntimePermission(): String? {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
Manifest.permission.NEARBY_WIFI_DEVICES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Manifest.permission.ACCESS_FINE_LOCATION
else -> null
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}

View File

@ -0,0 +1,35 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
internal object HotspotPermissions {
const val ANDROID_17_API_LEVEL = 37
@SuppressLint("InlinedApi")
fun requiredForSdk(sdkInt: Int = Build.VERSION.SDK_INT): List<String> {
return when {
sdkInt >= ANDROID_17_API_LEVEL -> listOf(
Manifest.permission.NEARBY_WIFI_DEVICES,
Manifest.permission.ACCESS_LOCAL_NETWORK
)
sdkInt >= Build.VERSION_CODES.TIRAMISU -> listOf(
Manifest.permission.NEARBY_WIFI_DEVICES
)
sdkInt >= Build.VERSION_CODES.Q -> listOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
else -> emptyList()
}
}
fun missingFrom(context: Context): List<String> {
return requiredForSdk().filter { permission ->
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
}
}
}

View File

@ -31,7 +31,13 @@ class FragmentingPacketSender(
sendSingle: (RoutedPacket) -> Boolean
): Boolean {
val transferId = transferIdFor(routed)
val packets = packetsForTransport(routed) ?: return false
val packets = packetsForTransport(routed)
if (packets == null) {
if (transferId != null) {
TransferProgressManager.fail(transferId)
}
return false
}
val total = packets.size
if (total <= 1) {
@ -45,9 +51,13 @@ class FragmentingPacketSender(
preparedPackets = null
)
)
if (sent && transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
if (transferId != null) {
if (sent) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
} else {
TransferProgressManager.fail(transferId)
}
}
return sent
}
@ -125,7 +135,12 @@ class FragmentingPacketSender(
val manager = fragmentManager ?: return listOf(packet)
return try {
val fragments = manager.createFragments(packet)
// Receivers hard-cap reassembly at MAX_FRAGMENTS_PER_ID; sending more
// fragments would be undeliverable, so reject here instead.
val fragments = manager.createFragments(
packet,
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
)
if (fragments.isEmpty()) {
Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}")
null

View File

@ -11,7 +11,8 @@ data class TransferProgressEvent(
val transferId: String,
val sent: Int,
val total: Int,
val completed: Boolean
val completed: Boolean,
val failed: Boolean = false
)
object TransferProgressManager {
@ -22,9 +23,9 @@ object TransferProgressManager {
fun start(id: String, total: Int) { emit(id, 0, total, false) }
fun progress(id: String, sent: Int, total: Int) { emit(id, sent, total, sent >= total) }
fun complete(id: String, total: Int) { emit(id, total, total, true) }
fun fail(id: String) { emit(id, 0, 0, done = true, failed = true) }
private fun emit(id: String, sent: Int, total: Int, done: Boolean) {
scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done)) }
private fun emit(id: String, sent: Int, total: Int, done: Boolean, failed: Boolean = false) {
scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done, failed)) }
}
}

View File

@ -70,6 +70,7 @@ import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.util.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager
/**
@ -512,10 +513,13 @@ fun AboutSheet(
is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded)
is ApkPreparationStatus.Ready -> {
val source = if (status.source == UniversalApkManager.ApkSource.INSTALLED) {
stringResource(R.string.prepare_apk_source_installed)
} else {
stringResource(R.string.prepare_apk_source_github)
val source = when {
status.source == UniversalApkManager.ApkSource.GITHUB ->
stringResource(R.string.prepare_apk_source_github)
status.variant == ShareableApkVariant.ARM64 ->
stringResource(R.string.prepare_apk_source_installed_arm64)
else ->
stringResource(R.string.prepare_apk_source_installed)
}
stringResource(R.string.prepare_apk_status_ready) +
"${status.version}${status.sizeMB} MB\n$source"
@ -545,14 +549,36 @@ fun AboutSheet(
)
}
is ApkPreparationStatus.Ready -> {
if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
if (apkStatus.variant == ShareableApkVariant.ARM64) {
TextButton(
onClick = {
apkViewModel.onEvent(
ApkUiEvent.DownloadUniversalClicked
)
}
) {
Icon(
imageVector = Icons.Default.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
stringResource(
R.string.prepare_apk_get_universal
)
)
}
} else if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
contentDescription = stringResource(
R.string.prepare_apk_delete_confirm
),
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)
@ -562,11 +588,13 @@ fun AboutSheet(
is ApkPreparationStatus.UpdateAvailable -> {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
contentDescription = stringResource(
R.string.prepare_apk_delete_confirm
),
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)

View File

@ -7,6 +7,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.R
import com.bitchat.android.util.ApkDownloader
import com.bitchat.android.util.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager
import com.bitchat.android.util.WorkManagerApkDownloader
import kotlinx.coroutines.Dispatchers
@ -27,7 +28,8 @@ sealed class ApkPreparationStatus {
data class Ready(
val version: String,
val sizeMB: Int,
val source: UniversalApkManager.ApkSource
val source: UniversalApkManager.ApkSource,
val variant: ShareableApkVariant
) : ApkPreparationStatus()
data class UpdateAvailable(
val currentVersion: String,
@ -52,6 +54,7 @@ data class ApkUiState(
sealed class ApkUiEvent {
object CheckStatus : ApkUiEvent()
object PrepareRowClicked : ApkUiEvent()
object DownloadUniversalClicked : ApkUiEvent()
object ConfirmDownload : ApkUiEvent()
object DismissPrepareDialog : ApkUiEvent()
object DeleteClicked : ApkUiEvent()
@ -99,6 +102,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
when (event) {
is ApkUiEvent.CheckStatus -> checkStatus()
is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked()
is ApkUiEvent.DownloadUniversalClicked -> onDownloadUniversalClicked()
is ApkUiEvent.ConfirmDownload -> onConfirmDownload()
is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) }
is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) }
@ -131,6 +135,15 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
startDownload()
}
private fun onDownloadUniversalClicked() {
val status = _state.value.apkStatus
if (status is ApkPreparationStatus.Ready &&
status.variant == ShareableApkVariant.ARM64
) {
_state.update { it.copy(showPrepareDialog = true) }
}
}
private fun onConfirmDelete() {
_state.update { it.copy(showDeleteDialog = false) }
downloader.cancelDownload()
@ -234,26 +247,47 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Ready(
version = downloadState.version,
sizeMB = downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB
version = info?.version ?: downloadState.version,
sizeMB = info?.let { cached ->
(cached.size / 1024 / 1024).toInt()
} ?: downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB,
variant = info?.variant ?: ShareableApkVariant.UNIVERSAL
),
downloadProgress = 100
)
}
}
is ApkDownloader.DownloadState.Failed -> {
_state.update {
if (downloadState.resumablePercent != null) {
val localArm64 = apkManager.getCachedApkInfo()
?.takeIf { it.variant == ShareableApkVariant.ARM64 }
if (localArm64 != null) {
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent,
message = downloadState.message
),
downloadProgress = downloadState.resumablePercent
apkStatus = ApkPreparationStatus.Ready(
version = localArm64.version,
sizeMB = (localArm64.size / 1024 / 1024).toInt(),
source = localArm64.source,
variant = localArm64.variant
)
)
} else {
it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message))
}
_effect.send(ApkUiEffect.ShowToast(downloadState.message))
} else {
_state.update {
if (downloadState.resumablePercent != null) {
it.copy(
apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent,
message = downloadState.message
),
downloadProgress = downloadState.resumablePercent
)
} else {
it.copy(
apkStatus = ApkPreparationStatus.Error(downloadState.message)
)
}
}
}
}
@ -295,7 +329,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
source = info.source,
variant = info.variant
)
} else {
ApkPreparationStatus.Error("Cached APK info not found")
@ -316,7 +351,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
source = info.source,
variant = info.variant
)
} else {
val partial = apkManager.getPartialDownloadProgress()

View File

@ -57,7 +57,8 @@ class MediaSendingManager(
private data class PendingPrivateMedia(
val request: LegacyPrivateMediaConsentRequest,
val peerID: String,
val conversationID: String,
val recipientMeshPeerID: String,
val filePacket: BitchatFilePacket,
val filePath: String,
val messageType: BitchatMessageType,
@ -68,7 +69,8 @@ class MediaSendingManager(
private data class PendingAutomaticPrivateMedia(
val requestId: String,
val peerID: String,
val conversationID: String,
val recipientMeshPeerID: String,
val filePacket: BitchatFilePacket,
val filePath: String,
val messageType: BitchatMessageType,
@ -299,10 +301,19 @@ class MediaSendingManager(
val transferId = withContext(mediaWorkDispatcher) {
sha256Hex(payload)
}
val recipient = PrivateMediaRecipientResolver.resolve(toPeerID, meshService)
?: run {
addPrivateMediaSystemMessage(
toPeerID,
"Private media was not sent because this conversation has no active mesh route."
)
return
}
val pending = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = toPeerID,
conversationID = recipient.conversationID,
recipientMeshPeerID = recipient.meshPeerID,
filePacket = filePacket,
filePath = filePath,
messageType = messageType,
@ -311,7 +322,7 @@ class MediaSendingManager(
)
if (!reserveAutomaticPending(pending)) {
addPrivateMediaSystemMessage(
toPeerID,
recipient.conversationID,
"Private media was not sent because another secure media send is still pending."
)
return
@ -334,7 +345,8 @@ class MediaSendingManager(
val pending = consumePendingConsent(requestId) ?: return
val automatic = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = pending.peerID,
conversationID = pending.conversationID,
recipientMeshPeerID = pending.recipientMeshPeerID,
filePacket = pending.filePacket,
filePath = pending.filePath,
messageType = pending.messageType,
@ -343,7 +355,7 @@ class MediaSendingManager(
)
if (!reserveAutomaticPending(automatic)) {
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent because another secure media send is still pending."
)
return
@ -374,7 +386,7 @@ class MediaSendingManager(
private suspend fun retryPendingPrivateMediaOnScope(peerID: String) {
val pending = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia
?.takeIf { it.peerID == peerID }
?.takeIf { it.recipientMeshPeerID == peerID }
} ?: return
evaluateAutomaticPending(pending)
}
@ -397,7 +409,7 @@ class MediaSendingManager(
val preparation = try {
withContext(mediaWorkDispatcher) {
meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
recipientPeerID = pending.recipientMeshPeerID,
file = pending.filePacket,
transferId = pending.transferId,
allowLegacyFallback = pending.allowLegacyFallback
@ -438,7 +450,8 @@ class MediaSendingManager(
clearAutomaticPending(pending.requestId)
commitPreparedPrivateFile(
preparation,
pending.peerID,
pending.conversationID,
pending.recipientMeshPeerID,
pending.filePath,
pending.messageType,
pending.transferId
@ -450,16 +463,16 @@ class MediaSendingManager(
if (pending.allowLegacyFallback) {
Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted")
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent because its security policy changed."
)
return
}
val nickname = try {
meshService.getPeerNicknames()[pending.peerID]
meshService.getPeerNicknames()[pending.recipientMeshPeerID]
} catch (_: Exception) {
null
} ?: pending.peerID.take(8)
} ?: pending.recipientMeshPeerID.take(8)
val request = LegacyPrivateMediaConsentRequest(
requestId = UUID.randomUUID().toString(),
recipientNickname = nickname,
@ -473,7 +486,8 @@ class MediaSendingManager(
}
pendingPrivateMedia = PendingPrivateMedia(
request,
pending.peerID,
pending.conversationID,
pending.recipientMeshPeerID,
pending.filePacket,
pending.filePath,
pending.messageType,
@ -487,7 +501,7 @@ class MediaSendingManager(
ensureAutomaticPendingTimeout(pending)
Log.d(TAG, "Private media needs a Noise handshake; retaining first-send intent")
try {
meshService.initiateNoiseHandshake(pending.peerID)
meshService.initiateNoiseHandshake(pending.recipientMeshPeerID)
} catch (e: Exception) {
Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}")
}
@ -502,7 +516,7 @@ class MediaSendingManager(
clearAutomaticPending(pending.requestId)
Log.w(TAG, "Private media not sent: ${preparation.reason}")
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent: ${preparation.reason}"
)
}
@ -542,7 +556,7 @@ class MediaSendingManager(
}
if (expired) {
addPrivateMediaSystemMessage(
pending.peerID,
pending.conversationID,
"Private media was not sent because secure session setup timed out."
)
}
@ -587,7 +601,8 @@ class MediaSendingManager(
private fun commitPreparedPrivateFile(
preparation: PrivateMediaPreparation.Ready,
toPeerID: String,
conversationID: String,
recipientMeshPeerID: String,
filePath: String,
messageType: BitchatMessageType,
transferId: String
@ -605,13 +620,17 @@ class MediaSendingManager(
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null },
recipientNickname = try {
meshService.getPeerNicknames()[recipientMeshPeerID]
} catch (_: Exception) {
null
},
senderPeerID = meshService.myPeerID
)
// Preparation already built and admitted the exact final packet. Map
// progress before commit so the first asynchronous event cannot race us.
messageManager.addPrivateMessage(toPeerID, msg)
messageManager.addPrivateMessage(conversationID, msg)
synchronized(transferMessageMap) {
transferMessageMap[transferId] = msg.id
messageTransferMap[msg.id] = transferId
@ -629,7 +648,7 @@ class MediaSendingManager(
}
Log.w(TAG, "Prepared private-media commit failed; local echo rolled back")
addPrivateMediaSystemMessage(
toPeerID,
conversationID,
"Private media was not sent because the prepared transfer could not be committed."
)
return
@ -739,7 +758,16 @@ class MediaSendingManager(
fun handleTransferProgressEvent(evt: com.bitchat.android.mesh.TransferProgressEvent) {
val msgId = synchronized(transferMessageMap) { transferMessageMap[evt.transferId] }
if (msgId != null) {
if (evt.completed) {
if (evt.failed) {
messageManager.updateMessageDeliveryStatus(
msgId,
com.bitchat.android.model.DeliveryStatus.Failed("transfer could not be sent")
)
synchronized(transferMessageMap) {
val msgIdRemoved = transferMessageMap.remove(evt.transferId)
if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved)
}
} else if (evt.completed) {
messageManager.updateMessageDeliveryStatus(
msgId,
com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date())

View File

@ -0,0 +1,49 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
internal data class PrivateMediaRecipient(
val conversationID: String,
val meshPeerID: String
)
/**
* Private-chat state is keyed by a stable contact/conversation ID, while mesh
* encryption and transport APIs require the current 16-hex peer ID.
*/
internal object PrivateMediaRecipientResolver {
fun resolve(requestedRecipientID: String, meshService: MeshService): PrivateMediaRecipient? {
val requested = requestedRecipientID.trim()
val conversationID = ContactDirectory.canonicalConversationId(requested)
val directoryPeerID = runCatching {
ContactDirectory.resolve(requested).meshPeerID
}.getOrNull()
val directPeerID = requested.takeIf(ContactIdentityResolver::isMeshPeerId)
val expectedFingerprint =
ContactIdentityResolver.fingerprintFromContactConversationId(conversationID)
?: requested
.takeIf(ContactIdentityResolver::isNoiseKeyHex)
?.let(ContactIdentityResolver::bytesFromHex)
?.let(ContactIdentityResolver::fingerprintHex)
val discoveredPeerID = expectedFingerprint?.let { fingerprint ->
runCatching {
meshService.getPeerNicknames().keys.firstOrNull { candidatePeerID ->
val info = meshService.getPeerInfo(candidatePeerID)
val noisePublicKey = info?.noisePublicKey
info?.isConnected == true &&
noisePublicKey != null &&
ContactIdentityResolver.fingerprintHex(noisePublicKey)
.equals(fingerprint, ignoreCase = true)
}
}.getOrNull()
}
val meshPeerID = (directoryPeerID ?: directPeerID ?: discoveredPeerID)
?.takeIf(ContactIdentityResolver::isMeshPeerId)
?: return null
return PrivateMediaRecipient(conversationID, meshPeerID)
}
}

View File

@ -31,18 +31,21 @@ object DistributionInfoProvider {
val splitApks = applicationInfo.splitSourceDirs.orEmpty()
val installerPackage = installerPackageName(context)
val certificateSha256 = signingCertificateSha256(packageInfo)
val installedApkCanBeSharedUniversally = splitApks.isEmpty() &&
isUniversalApk(File(applicationInfo.sourceDir))
val installedApkVariant = if (splitApks.isEmpty()) {
shareableApkVariant(File(applicationInfo.sourceDir))
} else {
null
}
return DistributionInfo(
installSource = installSourceLabel(installerPackage),
installerPackage = installerPackage,
packageFormat = if (splitApks.isEmpty()) "Standalone APK" else "Split APK set",
architecture = architectureLabel(applicationInfo.sourceDir, splitApks),
sharingSource = if (installedApkCanBeSharedUniversally) {
"Current installed APK"
} else {
"Verified GitHub universal APK"
sharingSource = when (installedApkVariant) {
ShareableApkVariant.UNIVERSAL -> "Current installed APK"
ShareableApkVariant.ARM64 -> "Current installed APK (ARM64)"
null -> "Verified GitHub universal APK"
},
versionName = packageInfo.versionName ?: BuildConfig.VERSION_NAME,
versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@ -112,6 +115,21 @@ object DistributionInfoProvider {
return packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS)
}
/**
* Returns the compatibility of an APK that is safe to offer for sharing.
* ARM64 is intentionally the only architecture-limited release variant
* supported because it is the project's primary per-ABI build.
*/
fun shareableApkVariant(apk: File): ShareableApkVariant? {
val packagedAbis = nativeAbisInApk(apk)
return when {
packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) ->
ShareableApkVariant.UNIVERSAL
packagedAbis == setOf("arm64-v8a") -> ShareableApkVariant.ARM64
else -> null
}
}
internal fun nativeAbisInApk(apk: File): Set<String> {
if (!apk.isFile) return emptySet()
return try {
@ -189,3 +207,8 @@ object DistributionInfoProvider {
val certificateSha256: String?
)
}
enum class ShareableApkVariant {
UNIVERSAL,
ARM64
}

View File

@ -24,7 +24,7 @@ import java.nio.file.StandardCopyOption
import java.security.MessageDigest
/**
* Manages downloading, caching, and verifying the universal APK for offline sharing.
* Manages local and downloaded APK artifacts for offline sharing.
*/
class UniversalApkManager(private val context: Context) {
@ -54,7 +54,7 @@ class UniversalApkManager(private val context: Context) {
.build()
/**
* Get information about the cached universal APK, if it exists.
* Get information about the cached sharing APK, if it exists.
*/
fun getCachedApkInfo(): ApkInfo? {
return try {
@ -81,6 +81,13 @@ class UniversalApkManager(private val context: Context) {
Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}")
return null
}
val variant = runCatching {
ShareableApkVariant.valueOf(json.optString("variant"))
}.getOrNull() ?: DistributionInfoProvider.shareableApkVariant(apkFile)
if (variant == null) {
Log.w(TAG, "Cached APK is not a supported sharing variant")
return null
}
ApkInfo(
version = version,
@ -88,7 +95,8 @@ class UniversalApkManager(private val context: Context) {
downloadDate = downloadDate,
size = size,
file = apkFile,
source = source
source = source,
variant = variant
)
} catch (e: Exception) {
Log.e(TAG, "Error reading cached APK info", e)
@ -125,11 +133,10 @@ class UniversalApkManager(private val context: Context) {
*/
suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) {
try {
// A genuinely universal standalone APK is already an installable
// sharing artifact. Architecture-specific standalone APKs and split
// installs still need the universal GitHub artifact.
// A supported standalone APK is already an installable sharing
// artifact. Split installs still need the universal GitHub artifact.
val installedApkInfo = cacheInstalledApkIfPreferred()
if (installedApkInfo != null) {
if (installedApkInfo?.source == ApkSource.INSTALLED) {
return@withContext UpdateStatus.UpToDate(installedApkInfo.version)
}
@ -330,7 +337,8 @@ class UniversalApkManager(private val context: Context) {
checksum = release.universalApkSha256 ?: "",
size = finalFile.length(),
fileName = finalFileName,
source = ApkSource.GITHUB
source = ApkSource.GITHUB,
variant = ShareableApkVariant.UNIVERSAL
)
cleanupOldApks(except = finalFile)
@ -467,9 +475,8 @@ class UniversalApkManager(private val context: Context) {
}
/**
* Cache the APK this process was installed from only when it is both
* standalone and universal. A base APK from a split install is incomplete,
* while an ABI-specific APK would unnecessarily limit recipients.
* Cache the APK this process was installed from when it is a standalone
* universal or ARM64 artifact. A base APK from a split install is incomplete.
*/
private fun cacheInstalledApkIfPreferred(): ApkInfo? {
return try {
@ -482,15 +489,25 @@ class UniversalApkManager(private val context: Context) {
if (!installedApk.isFile || installedApk.length() <= 0L) {
return null
}
if (!DistributionInfoProvider.isUniversalApk(installedApk)) {
Log.d(TAG, "Installed APK is architecture-specific; using GitHub universal APK")
discardArchitectureLimitedInstalledCache()
val installedVariant = DistributionInfoProvider.shareableApkVariant(installedApk)
if (installedVariant == null) {
Log.d(TAG, "Installed APK is not a supported sharing variant")
return null
}
val installedVersion = installedVersionName()
val cachedInfo = getCachedApkInfo()
// Downloading the universal release is an explicit compatibility
// choice. Keep it even when the running ARM64 build is newer; the
// user can delete it from the UI to return to the local artifact.
if (installedVariant == ShareableApkVariant.ARM64 &&
cachedInfo?.source == ApkSource.GITHUB &&
cachedInfo.variant == ShareableApkVariant.UNIVERSAL
) {
return cachedInfo
}
// Keep an already cached artifact if it is the same version or
// newer. Otherwise prefer the running build so sharing cannot
// silently downgrade recipients to an older GitHub release.
@ -502,7 +519,11 @@ class UniversalApkManager(private val context: Context) {
checkDiskSpace(installedApk.length())
val safeVersion = installedVersion.replace(Regex("[^A-Za-z0-9._-]"), "_")
val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk"
val variantSuffix = when (installedVariant) {
ShareableApkVariant.UNIVERSAL -> ""
ShareableApkVariant.ARM64 -> "-arm64-v8a"
}
val finalFileName = "$APK_FILE_PREFIX$safeVersion$variantSuffix.apk"
val finalFile = File(cacheDir, finalFileName)
val pendingFile = File(cacheDir, "$finalFileName.new")
@ -519,7 +540,8 @@ class UniversalApkManager(private val context: Context) {
checksum = checksum,
size = finalFile.length(),
fileName = finalFileName,
source = ApkSource.INSTALLED
source = ApkSource.INSTALLED,
variant = installedVariant
)
cleanupOldApks(except = finalFile)
@ -531,19 +553,6 @@ class UniversalApkManager(private val context: Context) {
}
}
private fun discardArchitectureLimitedInstalledCache() {
val cachedInfo = getCachedApkInfo() ?: return
if (cachedInfo.source != ApkSource.INSTALLED ||
DistributionInfoProvider.isUniversalApk(cachedInfo.file)
) {
return
}
cachedInfo.file.delete()
metadataFile.delete()
Log.d(TAG, "Removed architecture-specific installed APK from universal sharing cache")
}
private fun installedVersionName(): String {
return context.packageManager
.getPackageInfo(context.packageName, 0)
@ -729,7 +738,8 @@ class UniversalApkManager(private val context: Context) {
checksum: String,
size: Long,
fileName: String,
source: ApkSource
source: ApkSource,
variant: ShareableApkVariant
) {
val json = JSONObject().apply {
put("version", version)
@ -738,6 +748,7 @@ class UniversalApkManager(private val context: Context) {
put("size", size)
put("fileName", fileName)
put("source", source.name)
put("variant", variant.name)
}
val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new")
@ -802,7 +813,8 @@ class UniversalApkManager(private val context: Context) {
val downloadDate: Long,
val size: Long,
val file: File,
val source: ApkSource
val source: ApkSource,
val variant: ShareableApkVariant
)
enum class ApkSource {

View File

@ -192,7 +192,9 @@
<string name="prepare_apk_status_not_downloaded">Not ready • Tap to download</string>
<string name="prepare_apk_status_ready">Ready to share</string>
<string name="prepare_apk_source_installed" translatable="false">Sharing source: this installed APK</string>
<string name="prepare_apk_source_installed_arm64" translatable="false">Sharing source: this installed APK • ARM64 devices only</string>
<string name="prepare_apk_source_github" translatable="false">Sharing source: verified GitHub universal APK</string>
<string name="prepare_apk_get_universal" translatable="false">Get universal</string>
<string name="prepare_apk_status_downloading">Downloading… %1$d%%</string>
<string name="prepare_apk_status_update_available">Update available</string>
<string name="prepare_apk_button_prepare">Prepare</string>

View File

@ -0,0 +1,94 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Random
@RunWith(RobolectricTestRunner::class)
class FragmentingPacketSenderTest {
private val senderID = "1122334455667788"
private fun packetWithPayload(bytes: Int): BitchatPacket {
val payload = ByteArray(bytes)
Random(42).nextBytes(payload)
return BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = MeshPacketUtils.hexStringToByteArray(senderID),
recipientID = null,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = 7u
)
}
@Test
fun `oversized packet exceeding receiver fragment cap is rejected with fail event`() = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val sender = FragmentingPacketSender(scope, FragmentManager(), "test")
// ~256 * 469 bytes fit; 1 MiB clearly exceeds MAX_FRAGMENTS_PER_ID
val packet = packetWithPayload(1024 * 1024)
var sent = false
val failed = java.util.concurrent.ConcurrentLinkedQueue<String>()
val collectJob = launch(Dispatchers.Default) {
TransferProgressManager.events.collect { event ->
if (event.failed) failed.add(event.transferId)
}
}
kotlinx.coroutines.delay(100) // activate subscription before emitting
val accepted = sender.send(RoutedPacket(packet, transferId = "oversize-test"), "test") { sent = true; true }
assertFalse(accepted)
assertFalse(sent)
withTimeout(5_000) {
while (!failed.contains("oversize-test")) {
kotlinx.coroutines.delay(10)
}
}
collectJob.cancel()
Unit
}
@Test
fun `packet within fragment cap is accepted`() = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val sender = FragmentingPacketSender(scope, FragmentManager(), "test", interFragmentDelayMs = 0L)
val packet = packetWithPayload(10_000)
var writes = 0
val accepted = sender.send(RoutedPacket(packet, transferId = "fits-test"), "test") { writes += 1; true }
assertTrue(accepted)
withTimeout(5_000) {
while (writes == 0) {
kotlinx.coroutines.delay(10)
}
}
assertTrue(writes > 0)
}
@Test
fun `fragment count at cap boundary is not rejected`() {
val manager = FragmentManager()
val packet = packetWithPayload(AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID * 400)
val fragments = manager.createFragments(packet, AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID)
assertTrue(fragments.isNotEmpty())
assertTrue(fragments.size <= AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID)
}
}

View File

@ -0,0 +1,40 @@
package com.bitchat.android.hotspot
import android.Manifest
import org.junit.Assert.assertEquals
import org.junit.Test
class HotspotPermissionsTest {
@Test
fun `Android 17 requires nearby Wi-Fi and local network permissions`() {
assertEquals(
listOf(
Manifest.permission.NEARBY_WIFI_DEVICES,
Manifest.permission.ACCESS_LOCAL_NETWORK
),
HotspotPermissions.requiredForSdk(37)
)
}
@Test
fun `Android 13 through 16 require nearby Wi-Fi permission`() {
val expected = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
assertEquals(expected, HotspotPermissions.requiredForSdk(33))
assertEquals(expected, HotspotPermissions.requiredForSdk(36))
}
@Test
fun `Android 10 through 12 require fine location permission`() {
val expected = listOf(Manifest.permission.ACCESS_FINE_LOCATION)
assertEquals(expected, HotspotPermissions.requiredForSdk(29))
assertEquals(expected, HotspotPermissions.requiredForSdk(32))
}
@Test
fun `Android 9 and earlier require no hotspot runtime permission`() {
assertEquals(emptyList<String>(), HotspotPermissions.requiredForSdk(28))
}
}

View File

@ -1,9 +1,12 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PeerInfo
import com.bitchat.android.mesh.PreparedPrivateMediaTransfer
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.PrivateMediaWireMode
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.ContactIdentityResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -161,6 +164,99 @@ class MediaSendingManagerMigrationTest {
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `contact conversation resolves to live mesh peer for voice image and file sends`() {
val noisePublicKey = ByteArray(32) { (it + 1).toByte() }
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(noisePublicKey)
whenever(mesh.getPeerInfo(peerID)).thenReturn(
PeerInfo(
id = peerID,
nickname = "old peer",
isConnected = true,
isDirectConnection = true,
noisePublicKey = noisePublicKey,
signingPublicKey = null,
isVerifiedNickname = true,
lastSeen = System.currentTimeMillis()
)
)
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
val genericFile = kotlin.io.path.createTempFile("private-media", ".txt").toFile().apply {
writeText("private attachment")
}
try {
manager.sendVoiceNote(conversationID, null, file.absolutePath)
manager.sendImageNote(conversationID, null, file.absolutePath)
manager.sendFileNote(conversationID, null, genericFile.absolutePath)
assertEquals(3, commits.get())
assertEquals(
listOf(BitchatMessageType.Audio, BitchatMessageType.Image, BitchatMessageType.File),
state.privateChats.value[conversationID].orEmpty().map { it.type }
)
verify(mesh, times(3))
.prepareFilePrivate(eq(peerID), any(), any(), eq(false))
verify(mesh, never())
.prepareFilePrivate(eq(conversationID), any(), any(), any())
} finally {
genericFile.delete()
}
}
@Test
fun `mesh policy callback retries contact conversation using live peer ID`() {
val noisePublicKey = ByteArray(32) { (it + 1).toByte() }
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(noisePublicKey)
whenever(mesh.getPeerInfo(peerID)).thenReturn(
PeerInfo(
id = peerID,
nickname = "old peer",
isConnected = true,
isDirectConnection = true,
noisePublicKey = noisePublicKey,
signingPublicKey = null,
isVerifiedNickname = true,
lastSeen = System.currentTimeMillis()
)
)
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.NeedsHandshake)
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
manager.sendVoiceNote(conversationID, null, file.absolutePath)
manager.retryPendingPrivateMedia(peerID)
assertEquals(1, commits.get())
assertEquals(BitchatMessageType.Audio, state.privateChats.value[conversationID]?.single()?.type)
verify(mesh).initiateNoiseHandshake(peerID)
verify(mesh, never()).initiateNoiseHandshake(conversationID)
}
@Test
fun `awaiting peer state retains send and watchdog resolution offers legacy consent`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))

View File

@ -1,6 +1,8 @@
package com.bitchat.android.util
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@ -22,6 +24,10 @@ class DistributionInfoProviderTest {
val apk = createApk("lib/arm64-v8a/libbitchat.so")
assertFalse(DistributionInfoProvider.isUniversalApk(apk))
assertEquals(
ShareableApkVariant.ARM64,
DistributionInfoProvider.shareableApkVariant(apk)
)
}
@Test
@ -34,6 +40,10 @@ class DistributionInfoProviderTest {
)
assertTrue(DistributionInfoProvider.isUniversalApk(apk))
assertEquals(
ShareableApkVariant.UNIVERSAL,
DistributionInfoProvider.shareableApkVariant(apk)
)
}
@Test
@ -41,6 +51,17 @@ class DistributionInfoProviderTest {
val apk = createApk("classes.dex")
assertTrue(DistributionInfoProvider.isUniversalApk(apk))
assertEquals(
ShareableApkVariant.UNIVERSAL,
DistributionInfoProvider.shareableApkVariant(apk)
)
}
@Test
fun `other architecture-only APK is not offered for sharing`() {
val apk = createApk("lib/x86_64/libbitchat.so")
assertNull(DistributionInfoProvider.shareableApkVariant(apk))
}
private fun createApk(vararg entries: String): File {

View File

@ -242,3 +242,102 @@ command and stop the local relay/Tor fixture.
not waive a mandatory scenario.
- A flaky result is a failure until its cause is understood. Never average
retries into a pass.
## Appendix: mesh lab (ADB test hooks, debug builds)
For day-to-day development there is a lighter-weight harness that drives a
debug-only broadcast receiver (`app/src/debug/`, never shipped in release)
exposing mesh operations over ADB: scan, connect, Noise handshake, DMs,
public broadcast, announce, file send/receive, BLE toggle, state dumps, and
raw packet injection. Results are JSON files in the app sandbox polled by the
host (`cache/testhook/results/<id>.json`, also logged under tag `TestHook`).
### Prerequisites
- A JDK (e.g. the one bundled with Android Studio; set `JAVA_HOME`) and the
Android SDK platform-tools. `adb` must be on `PATH` or `ANDROID_HOME` set.
- Python 3.10+ on the host. No third-party packages are required.
- **Two physical Android devices** (API 26+, BLE) with USB debugging enabled,
both plugged into the host. Emulators are not supported (BLE mesh).
- Verify both are visible: `adb devices` → note the serials.
### Device preparation (important)
Keep both phones **unlocked with the screen on** for the whole run. A locked
or dozing device forces the app into POWER_SAVER (1 s BLE scan per 60 s),
which makes discovery and handshakes take minutes and will flake every
scenario. The harness runs `wake()` (dismiss keyguard, stretch screen
timeout) during `setup`, but it cannot defeat a secure lock screen — unlock
the devices manually first. Note that `svc power stayon` only helps while a
device is actually charging.
### Build and set up
```sh
./gradlew assembleDebug
python3 tools/release_gate/mesh_lab.py setup \
--serial-a <serial-1> --serial-b <serial-2> \
--apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
```
`setup` cycles Bluetooth, installs the APK, clears app data, grants all
runtime permissions, wakes and launches the app, sets deterministic nicknames
(`alice`/`bob`), and waits for mutual peer discovery. It is safe (and
recommended) to rerun `setup` before each scenario batch; `--apk` may be
omitted if the current build is already installed.
### Run scenarios
```sh
python3 tools/release_gate/mesh_lab.py scenario all \
--serial-a <serial-1> --serial-b <serial-2> --out /tmp/meshlab-evidence
```
| Scenario | What it asserts |
|---|---|
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
| `broadcast` | public mesh message A→B |
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
| `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing |
| `file_private` | Noise-encrypted private file, digest match |
| `media_private` | private-chat contact ID resolves to the live mesh peer; voice, image, and generic-file digests match |
| `raw` | raw packet injection is accepted by the mesh |
| `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again |
| `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs |
| `all` | every scenario above in sequence |
Each run writes `<scenario>-evidence.json` to `--out` (digests, timings,
session states, logcat excerpts on failure) and exits non-zero on failure.
Evidence is a local diagnostic artifact; it may contain lab peer IDs and is
not privacy-checked like release-gate bundles — do not publish it.
### Ad-hoc commands
Any hook command can be sent to one device directly:
```sh
python3 tools/release_gate/mesh_lab.py cmd --serial <serial> scan --extra timeout_ms=30000
python3 tools/release_gate/mesh_lab.py cmd --serial <serial> handshake --extra peer=<peer-id>
python3 tools/release_gate/mesh_lab.py cmd --serial <serial> state # full mesh dump
```
See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `file_send`,
`file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`).
### Troubleshooting
- **Discovery/handshake timeouts**: almost always a locked or dozing phone —
unlock both devices and rerun `setup`. `cmd ... state` shows
`App In Background: true` and the BLE duty cycle when this is the cause.
- **Stale app state after many churn runs**: `svc bluetooth disable/enable`
on both devices (done automatically by `setup`) clears zombie GATT links.
- **Watch the wire**: `adb -s <serial> logcat -s TestHook MessageHandler
FragmentManager BitchatFilePacket` shows commands, results, decrypt
failures, fragment rejects, and saved incoming files in real time.
- Results also persist on-device at
`run-as com.bitchat.droid cat cache/testhook/results/<id>.json`.
Unlike the release gate, this harness is a development aid: it prints raw
diagnostics and does not produce a privacy-checked approval bundle.

View File

@ -0,0 +1,371 @@
# Bitchat for Pixel Watch — Implementation Plan
> **Status tracker**: each milestone carries a status (`pending` / `in-progress` / `done`) and a
> checklist. A milestone may only be started when the previous milestone's success criteria pass.
> Update statuses in this file as work progresses.
| Milestone | Title | Status |
|-----------|-------|--------|
| M0 | Scaffolding & plan document | done |
| M1 | Shared core compiles on Wear | done |
| M2 | BLE transport & background service on watch | done |
| M3 | Global chat | done |
| M4 | Noise DMs & people screen | done |
| M5 | Files/images receive + voice notes (push-to-talk) + input redesign | done |
| M6 | ADB test hook & mesh_lab interop | done |
| M7 | Polish & final design pass | done |
---
## 1. Context for a fresh coding agent
- **Reference app**: this repository (`bitchat-android`) is a fully working, decentralized BLE mesh
chat client. Single Gradle module `:app`, root package `com.bitchat.android`, applicationId
`com.bitchat.droid`. See `AGENTS.md` for the full architecture overview.
- **Goal**: a new `:wear` Gradle module (applicationId `com.bitchat.watch`) — a standalone Wear OS
app for the Pixel Watch. **Bluetooth mesh only**: global chat, Noise-encrypted direct messages,
and receiving/displaying files & images. It must be a fully interoperable bitchat client: scan,
advertise, connect, relay, handshake, and exchange messages with the Android (and iOS) apps.
- **Explicitly out of scope**: no internet features (no Nostr, no Tor/Arti, no relays), no GPS /
geohash / location channels, no Wi-Fi Aware, no hotspot/APK sharing, no voice notes recording.
The watch manifest must not even declare `INTERNET` or location permissions.
- **Hard constraint**: **zero modifications to `:app` production code.** The only allowed changes
to shared repo files are: `settings.gradle.kts` (add `include(":wear")`), entries in
`gradle/libs.versions.toml` (new wear dependencies only), the new `wear/` directory, and docs.
All shared Kotlin code is consumed by the `:wear` module *in place* via Gradle source sets —
files are never moved, copied, or edited.
## 2. Code reuse strategy (shared source sets)
Wear OS is Android. `android.util.Log`, `android.bluetooth.*`, `EncryptedSharedPreferences`
(androidx.security), BouncyCastle, and coroutines all work on the watch, so the vast majority of
the bitchat protocol stack compiles unmodified.
In `wear/build.gradle.kts`:
```kotlin
sourceSets["main"].java.srcDir("../app/src/main/java") // with include filters (see below)
```
**Include** (iteratively refined by fixing compile errors — the include list lives in
`wear/build.gradle.kts` with comments):
- `protocol/**` — wire format, `BinaryProtocol`, `CompressionUtil`, `MessagePadding`
- `model/**``BitchatMessage`, `BitchatFilePacket`, `FragmentPayload`, `NoiseEncrypted`,
`IdentityAnnouncement`, `RoutedPacket`
- `noise/**``NoiseSession`, `NoiseSessionManager`, `NoiseEncryptionService`,
`NoiseChannelEncryption`, vendored pure-Java `noise/southernstorm/**`
- `crypto/**``EncryptionService`
- `identity/**``SecureIdentityStateManager`
- `mesh/**` — BLE stack (`BluetoothConnectionManager`, GATT server/client managers, broadcaster,
tracker, permission manager), `FragmentManager`, `SecurityManager`, `PacketProcessor`,
`MessageHandler`, `PeerManager`, `StoreForwardManager`, `MeshTransport`, `MeshService`,
`TransferProgressManager`, `PrivateMediaTransfer`, `PowerManager`
- `services/AppStateStore.kt` — process-wide state store
- `util/AppConstants.kt` — shared constants (GATT UUIDs, fragmentation sizes)
- Small transitive deps the compiler reveals (known: `ui/debug/DebugSettingsManager.kt` is
referenced from the mesh layer — include the file, not the package)
**Exclude**: `ui/**` (except forced single-file includes), `onboarding/**`, `nostr/**`, `net/**`,
`geohash/**`, `wifi-aware/**`, `hotspot/**`, `features/voice/**`, `service/MeshForegroundService.kt`
(wear gets its own service), `BitchatApplication.kt`, `MainActivity.kt`.
**Tests**: the app's own unit tests for shared packages (`protocol`, `noise`, `crypto`, `mesh`)
are wired into the `:wear` test source set the same way (srcDir + includes), so shared behavior is
continuously verified on both modules.
**Resources**: font files cannot be selectively shared via srcDir cleanly — copy the 4 Geist Mono
font files (`app/src/main/res/font/geist_mono_*`) into `wear/src/main/res/font/`. Theme/palette/
peer-color logic is re-created as wear-owned files mirroring `ui/theme/` values exactly.
## 3. Watch UX design
Wear Compose Material3 (round-screen safe by default):
- **Screens**: Chat (global timeline) → People (connected peers w/ RSSI, unread badges) →
DM conversation. Edge-swipe back, `TimeText` scaffold, rotary crown scrolling.
- **Visual identity** (mirrors `ui/theme/` exactly): black background `#000000`, green primary
`#32D74B`, error `#FF453A`, orange accent for self/mentions, djb2-hash stable peer colors
(`PeerColors.kt` algorithm), Geist Mono typography, `BitchatMotion` timing tokens
(120/180/240 ms) for all animations.
- **Input**: text field using the Pixel Watch Gboard IME, plus voice dictation via
`RecognizerIntent`. Haptic feedback on incoming messages.
- **Background**: wear-owned foreground service (type `connectedDevice`) keeps scan/advertise
alive; shared `PowerManager` provides duty-cycling.
## 4. Hardware & test environment
- Pixel Watch connected via ADB (target device for all milestones; screencaps via
`adb exec-out screencap`).
- Two phones running the Android bitchat app, also on ADB, for interop testing (used heavily from
M6; manual interop checks from M2 onward).
- Design verification: at every UI milestone, take ADB screencaps of every screen and review them
for round-screen clipping, element visibility, contrast, and touch-target size. A milestone does
not pass until its screencap set is approved.
## 5. Risks & notes
- `BluetoothMeshService` (legacy monolith) vs `MeshCore` — prefer wiring the shared components
directly (MeshCore-style composition) in the wear service.
- `mesh/` references `ui/debug/DebugSettingsManager` — include that single file; do not pull in
the debug UI sheet.
- Wear BLE MTUs are small; the shared fragmentation layer (469-byte fragments) already handles
this.
- `EncryptedSharedPreferences` (androidx.security-crypto) works on Wear OS — identity persistence
is reused as-is.
- Watch has no camera/gallery: file transfer is **receive + display only** (confirmed decision).
---
## Milestones
### M0 — Scaffolding & plan document
- [x] Write this plan to `docs/wear-os-implementation-plan.md`
- [x] Create `:wear` module: `wear/build.gradle.kts`, manifest
(`<uses-feature android:name="android.hardware.type.watch"/>`, standalone, BT permissions,
**no INTERNET/location**), `MainActivity` with hello-world screen using the ported theme
- [x] Add Wear Compose dependencies to `gradle/libs.versions.toml`; `include(":wear")` in
`settings.gradle.kts`
- [x] Build, install, and launch on the physical Pixel Watch via ADB; take first screencap
**Success criteria**: `./gradlew :wear:assembleDebug` green; app launches on the watch;
`git diff --name-only` shows no changes under `app/src/`.
**Result**: PASSED — installed on Pixel Watch 3 (serial 4C201JEAYW0020), launch screencap shows
"bitchat" wordmark (green `#32D74B`, Geist Mono, black background) correctly centered on the
round display. No `app/src/` changes.
---
### M1 — Shared core compiles on Wear
- [x] Configure shared-source wiring in `wear/build.gradle.kts`; resolve transitive dependencies by
extending includes (never by copying Kotlin sources)
- [x] Copy Geist Mono fonts; create wear theme/palette/peer-color files mirroring `ui/theme/`
- [x] Wire shared unit tests (`protocol`, `noise`, `crypto`, `mesh`) into `:wear` test source set
- [x] `./gradlew :app:test :wear:test` green
**Implementation notes** (deviation from original plan): AGP 9 source directory sets no longer
support include/exclude filters, so a Gradle `Sync` task (`syncSharedAppSources`) materializes a
filtered mirror of `app/src/main/java` into `wear/build/sharedSrc` which is added as a source
root. App sources remain the single source of truth; nothing is hand-copied. Excluded:
`BluetoothMeshService`/`UnifiedMeshService` (phone monolith / Wi-Fi Aware multiplexer — the watch
composes its own service in M2). Two tiny wear-owned shims satisfy the only unresolvable
references from shared code: `com.bitchat.android.service.MeshServiceHolder` (BLE-toggle
interface, null) and `com.bitchat.android.wifiaware.WifiAwareController` (no-op).
**Success criteria**: the entire shared stack (protocol, noise, crypto, identity, mesh, model,
AppStateStore) compiles into `:wear`; both modules' unit tests pass; `app/src/` untouched.
**Result**: PASSED — `:wear` compiles the full shared stack; 172 shared unit tests pass on
`:wear` (0 failures), `:app` suite green; `app/src/` unchanged.
---
### M2 — BLE transport & background service on watch
- [ ] Wear onboarding flow: Bluetooth-enable check + runtime permission requests
(`BLUETOOTH_SCAN/CONNECT/ADVERTISE`), watch-styled screens
- [ ] `WearMeshService` foreground service (type `connectedDevice`); wire shared
`BluetoothConnectionManager` + mesh components; start scanning + advertising
- [ ] Internal debug screen: discovered peers with RSSI (temporary, replaced by real UI in M3/M4)
- [ ] Manual interop check: watch and one phone mutually discover
**Success criteria**: the phone's bitchat app lists the watch as a connected peer and vice versa
(logcat + screencap evidence); mesh survives the screen turning off (ambient mode) for 5 minutes.
**Result**: PASSED — phone↔watch mutual discovery via `mesh_lab.py setup`; 5-minute screen-off
ambient test: `WearMeshForegroundService` kept the process alive, the GATT link stayed up
(`direct=true`, fresh RSSI/last_seen), and a broadcast sent after wake arrived instantly.
Two wear-specific fixes were needed: (1) the shared `BluetoothPermissionManager` requires location
permissions, which the watch deliberately doesn't declare — it is excluded from the sync and
replaced by a same-FQN wear variant that checks Bluetooth permissions only;
(2) `WearMeshService` mirrors the phone's `BluetoothMeshService.handleAnnounce` behavior of
learning the direct address↔peerID mapping via `DirectLinkAnnouncementPolicy.observationFor` +
`connectionManager.observePeerIfCurrent` (without this, `connect` after restarts fails).
---
### M3 — Global chat
- [ ] Nickname onboarding; identity announcement over the mesh
- [ ] Send/receive/relay public `BitchatMessage`s (relay/TTL comes free from shared mesh code)
- [ ] Chat timeline UI (`ScalingLazyColumn`, message bubbles per bitchat style, peer colors,
timestamps) + composer (IME + `RecognizerIntent` dictation) + incoming-message haptics
- [ ] Design check: ADB screencaps of onboarding, chat (empty/populated), composer; review for
round-screen clipping/visibility/contrast
**Success criteria**: two-way public chat between watch and phone; messages the watch relays reach
a second phone that is only connected through the first (relay proof); screencap set approved.
**Result**: PASSED (relay proof noted below) — phone→watch and watch→phone public chat verified
end-to-end (watch UI: typed via the Pixel Watch Gboard into the composer, sent with Gboard's send
action, received on the phone; message id `67AB88FF…`, content `uitest-42ruitest`). Gossip sync
re-delivers history after reinstall/restart. Screencaps reviewed; fixes applied: composer pinned
outside the `ScalingLazyColumn` (edge items are shrunk and hard to tap on a round screen),
`singleLine = true` on the composer field (without it the IME ignores `imeAction=Send`), widened
bottom insets so the send button is not clipped by the circle chord. Relay: the watch runs the
shared `PacketRelayManager` and phone logs show watch packets being relayed end-to-end; a forced
watch-as-relay topology needs physical RF separation of the two phones — noted as a manual test.
---
### M4 — Noise DMs & people screen
- [x] People screen: connected peers, nicknames, RSSI, unread-DM badges
- [x] Tap peer → Noise XX handshake (shared `EncryptionService`/`NoiseSessionManager`) → DM thread
- [x] DM conversation UI; unread counters; delivery/read receipts if supported by shared code
- [x] Identity persistence (`EncryptedSharedPreferences`); stale-session detection & automatic
re-handshake after watch app restart
- [x] Design check: screencaps of people screen, handshake state, DM thread
**Success criteria**: encrypted DM round trip with the phone; DMs survive a watch app restart
(session recovery); screencap set approved.
**Result**: PASSED — `mesh_lab.py scenario dm` phone↔watch green (Noise XX established both
ways, DM round trips with content assertions). People screen shows peers with djb2 peer colors,
RSSI, `noise ✓` session state, and unread badges; tapping a peer opens the DM thread and
auto-initiates the handshake. Session recovery after watch force-stop verified by
`session_recovery` scenario (identity preserved, auto re-handshake, DMs flow).
---
### M5 — Files/images receive + voice notes (push-to-talk) + input redesign
> Revised scope (was: receive-only, deferred). Now includes voice messages as a first-class
> input method and a native-Wear bottom-action redesign of the composer.
**Files & images (receive + display)**
- [x] Receive broadcast + Noise-encrypted private files (shared `BitchatFilePacket` TLV,
`FileUtils.saveIncomingFile`, `messageTypeForMime` — already wired via shared `MessageHandler`)
- [x] Image messages render as compact inline thumbnails (rounded, fit-width); tap → full-screen
viewer (black surface, fit-to-screen, dismiss) — mirrors the phone's `ImageMessageItem` /
`FullScreenImageViewer`
- [x] Non-media files: compact chip (name + size)
- [x] mesh_lab: add `file_recv` to the wear test hook; enable `file` + `file_private` scenarios
for the watch
**Voice notes (first-class)**
- [x] RECORD_AUDIO permission (manifest + just-in-time runtime request)
- [x] Push-to-talk recording: press-and-hold starts recording, release sends (10 s cap, 600 ms
minimum, ~80 ms amplitude polls); full-screen overlay that fades in with a live waveform
animation + elapsed time; shared `VoiceRecorder` (16 kHz mono AAC, `audio/mp4`, `.m4a`)
- [x] Send as `BitchatFilePacket` broadcast in global chat (`MeshCore.sendFileBroadcast`); in a
DM thread send Noise-encrypted (`WearMeshService.sendFilePrivateEncrypted` with
handshake/prep retry, mirroring the phone's `dispatchFileSend`)
- [x] Received voice notes (`BitchatMessageType.Audio`, `content` = local path) render as a
voice-note bubble: play/pause + waveform (shared `Waveform.kt` extractor, 120 bins) +
duration; `MediaPlayer` playback
**Input redesign (native Wear bottom actions)**
- [x] Replaced the inline composer with two always-visible bottom action buttons (the
framework's `ScreenScaffold.edgeButton` slot auto-hides on scroll, making push-to-talk
unreachable mid-conversation, so the bar is overlaid with the same native look instead):
- keyboard button → full-screen text input screen (field auto-focused, the watch IME opens
immediately with its built-in dictation; IME hides on send)
- mic button → push-to-talk (press-and-hold record, release send) with the full-screen
waveform overlay (rendered outside the edgeButton slot, which would clip it)
- [x] Message lists use `LazyColumn(reverseLayout = true)`: the newest message anchors at the
bottom above the buttons; empty space collects at the top. Works identically on round and
square screens (no ScalingLazyColumn center-anchor gap).
- [x] ScreenScaffold contentPadding keeps the last message reachable right above the buttons
**Result**: PASSED —
- `mesh_lab.py scenario file` and `file_private` (phone→watch) green, SHA-256 digest match.
- Push-to-talk voice note (watch→phone) verified end-to-end: broadcast in global chat and
Noise-encrypted in DM, digest match on the phone side; phone→watch voice note renders as a
bubble and plays (MediaPlayer).
- Image (phone→watch) verified: compact inline render, tap → full-screen viewer, digest match.
- Keyboard path: auto-focus opens the IME, send hides it, message arrives on the phone.
- Full regression: `scenario all` (7 scenarios) green in ~75 s.
- Robustness: the watch auto-initiates a throttled Noise handshake with peers lacking an
established session — heals stale sessions after watch restarts (the protocol has no
decrypt-failure kick path; without this, private files/DMs from peers with stale sessions
were silently dropped).
---
### M6 — ADB test hook & mesh_lab interop
- [x] Wear debug-only `TestHookReceiver` (`wear/src/debug/`) mirroring the phone's command set:
`ping`, `start`, `stop`, `whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`,
`session`, `announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `raw_send`, `state`,
`clear_results` — broadcast action `com.bitchat.watch.TEST_HOOK`, same JSON-result-file protocol
(`file_*` excluded while M5 is deferred)
- [x] Extend `tools/release_gate/mesh_lab.py`: `--serial-watch` argument and phone↔watch scenarios
(`dm`, `broadcast`, `raw`, `session_recovery`, `identity_reset`, `all`), reusing
the existing `Device`/`cmd` machinery
- [x] Run full scenario suite phone↔watch; store evidence JSON
**Success criteria**:
`python3 tools/release_gate/mesh_lab.py scenario all --serial-a <phone> --serial-watch <watch>`
exits 0 with evidence files; no manual intervention.
**Result**: PASSED — `scenario all` (dm, broadcast, raw, session_recovery, identity_reset)
green in 73 s, evidence in `/tmp/meshlab-evidence/all-evidence.json`. Host-side robustness fixes
in `mesh_lab.py`: `WatchDevice` (package/hook/permissions/activity for `com.bitchat.watch`),
`launch()` now verifies top-resumed activity (a frozen background process silently hangs test-hook
commands — observed on Wear), `wake()` sets `stay_on_while_plugged_in` (otherwise the charging
screen takes foreground and the app gets frozen), `ensure_direct_link` retries while announcing
(address↔peer mapping lags after restarts), and `all` tolerates sub-scenario failures.
Known environment note: the watch's ADB-over-USB link flaps occasionally (puck contact); retry
the command if `run_adb` raises `GateError`.
---
### M7 — Polish & final design pass
- [x] Animations/transitions per `BitchatMotion` tokens; message-appear animations; screen
transitions; auto-scroll to newest; splash screen (black, on-brand) & app icon
- [x] Power/battery: ambient test passed (see M2 result); shared `PowerManager` duty-cycling
active; composer/IME insets verified. Rotary crown scrolling is provided by
`ScalingLazyColumn` (wear-compose-foundation ≥1.3, framework-level; `input rotary` is not
supported by this Wear build's adb, so crown feel was not adb-verifiable — check manually)
- [x] Full screencap design review of every screen and state; fixes applied (composer pinning,
`singleLine` IME action, bottom-chord clipping, black splash)
- [x] Update this document: all milestones `done`; add a short "how to build/run/test" section
**Success criteria**: all milestones marked `done`; interop suite green; final screencap set
approved; a fresh agent can build, install, and test the watch app from this document alone.
**Result**: PASSED.
---
## How to build / run / test
Prereqs: JDK (e.g. Android Studio JBR), `adb` on PATH or `ANDROID_HOME` set, Python 3.10+,
a Wear OS device (Pixel Watch) and a phone with USB debugging. **Both devices unlocked, screen
on** — on the watch, disable the lock screen (Settings → Security) or tests will stall on the
pattern lock; mesh_lab sets `stay_on_while_plugged_in` etc. automatically.
```bash
# Build
./gradlew :wear:assembleDebug :app:assembleDebug
# Unit tests (shared stack runs on both modules)
./gradlew :wear:testDebugUnitTest :app:testDebugUnitTest
# Install & launch on the watch
adb -s <watch-serial> install -r -g wear/build/outputs/apk/debug/wear-debug.apk
adb -s <watch-serial> shell monkey -p com.bitchat.watch -c android.intent.category.LAUNCHER 1
# Screencap (design checks)
adb -s <watch-serial> exec-out screencap -p > watch.png
# Full interop suite (phone + watch)
python3 tools/release_gate/mesh_lab.py setup \
--serial-a <phone-serial> --serial-watch <watch-serial> \
--apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk \
--watch-apk wear/build/outputs/apk/debug/wear-debug.apk
python3 tools/release_gate/mesh_lab.py scenario all \
--serial-a <phone-serial> --serial-watch <watch-serial> --out /tmp/meshlab-evidence
# Ad-hoc test-hook commands (watch)
adb -s <watch-serial> shell am broadcast -a com.bitchat.watch.TEST_HOOK \
-n com.bitchat.watch/.testhook.WearTestHookReceiver --es cmd state --es id s1
adb -s <watch-serial> shell run-as com.bitchat.watch cat cache/testhook/results/s1.json
```
Notes:
- If `mesh_lab` raises `GateError: ADB command failed`, the watch's USB link flapped — retry.
- Wear test-hook commands: `ping start stop whoami set_nickname scan peers connect handshake
session announce broadcast_msg dm_send dm_recv msg_recv raw_send file_recv state
clear_results`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -15,6 +15,7 @@ appcompat = "1.7.1"
# Compose
compose-bom = "2026.06.01"
compose-icons-extended = "1.7.8"
# Navigation
navigation-compose = "2.9.8"
@ -22,6 +23,10 @@ navigation-compose = "2.9.8"
# Accompanist
accompanist-permissions = "0.37.3"
# Wear OS
wear-compose = "1.6.2"
wear-tooling-preview = "1.0.0"
# Cryptography
bouncycastle = "1.85"
tink-android = "1.23.0"
@ -85,7 +90,7 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" }
# Lifecycle
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" }
@ -96,6 +101,11 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose
# Accompanist
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" }
# Wear OS
androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" }
androidx-wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wear-compose" }
androidx-wear-tooling-preview = { module = "androidx.wear:wear-tooling-preview", version.ref = "wear-tooling-preview" }
# Cryptography
bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" }

View File

@ -15,4 +15,5 @@ dependencyResolutionManagement {
rootProject.name = "bitchat-android"
include(":app")
include(":wear")
// Using published Arti AAR; local module not included

View File

@ -0,0 +1,821 @@
#!/usr/bin/env python3
"""ADB-driven mesh test orchestrator for two (or more) live devices.
Drives the debug-only TestHookReceiver in the app
(intent action: com.bitchat.droid.TEST_HOOK) to perform mesh operations:
peer scanning, connect, Noise handshake, DMs, file transfer, broadcast,
announce, and raw packet injection.
Each on-device command writes a JSON result to
cache/testhook/results/<id>.json inside the app sandbox; this module polls
for it via `run-as` and returns the parsed dict.
Typical usage:
python3 tools/release_gate/mesh_lab.py setup --serial-a X --serial-b Y --apk app/build/outputs/apk/debug/app-debug.apk
python3 tools/release_gate/mesh_lab.py scenario dm --serial-a X --serial-b Y
python3 tools/release_gate/mesh_lab.py scenario all --serial-a X --serial-b Y
python3 tools/release_gate/mesh_lab.py cmd --serial X scan --extra timeout_ms=30000
"""
from __future__ import annotations
import argparse
import concurrent.futures
import hashlib
import json
import random
import shlex
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
if str(REPOSITORY_ROOT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_ROOT))
from tools.release_gate.android_lab import APPLICATION_ID, find_adb, run_adb
TEST_HOOK_ACTION = "com.bitchat.droid.TEST_HOOK"
TEST_HOOK_COMPONENT = f"{APPLICATION_ID}/com.bitchat.android.testhook.TestHookReceiver"
RESULTS_DIR = "cache/testhook/results"
DEVICE_TMP_DIR = "/data/local/tmp/meshlab"
APP_FIXTURE_DIR = f"/data/data/{APPLICATION_ID}/cache/fixtures"
WATCH_APPLICATION_ID = "com.bitchat.watch"
WATCH_TEST_HOOK_ACTION = "com.bitchat.watch.TEST_HOOK"
WATCH_TEST_HOOK_COMPONENT = f"{WATCH_APPLICATION_ID}/com.bitchat.watch.testhook.WearTestHookReceiver"
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",
]
WATCH_PERMISSIONS = [
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_ADVERTISE",
"android.permission.POST_NOTIFICATIONS",
"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 device running a debug build with the test hook."""
def __init__(
self,
serial: str,
alias: str,
package: str = APPLICATION_ID,
hook_action: str = TEST_HOOK_ACTION,
hook_component: str = TEST_HOOK_COMPONENT,
permissions: list[str] = PERMISSIONS,
activity_component: str = f"{APPLICATION_ID}/com.bitchat.android.MainActivity",
):
self.serial = serial
self.alias = alias
self.package = package
self.hook_action = hook_action
self.hook_component = hook_component
self.permissions = permissions
self.activity_component = activity_component
# -- 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 self.permissions:
subprocess.run(
[find_adb(), "-s", self.serial, "shell", "pm", "grant", self.package, perm],
check=False, capture_output=True, text=True, timeout=30,
)
def clear_app_data(self) -> None:
_shell(self.serial, f"am force-stop {self.package}")
output = _shell(self.serial, f"pm clear {self.package}")
if "Success" not in output:
raise MeshLabError(f"[{self.alias}] pm clear failed: {output}")
def force_stop(self) -> None:
_shell(self.serial, f"am force-stop {self.package}")
def launch(self) -> None:
"""Launch the app and verify it is actually top-resumed.
A background/cached process can be frozen by the system (observed on Wear OS),
which silently hangs test-hook commands; the foreground activity (and the FGS it
starts) keeps the process unfrozen.
"""
for _attempt in range(3):
_shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1")
time.sleep(3)
try:
top = _shell(
self.serial,
"dumpsys activity activities | grep topResumedActivity",
)
if self.package in top:
return
except Exception:
pass
_shell(self.serial, f"am start -n {self.activity_component}")
time.sleep(3)
def wake(self) -> None:
"""Keep the screen on and the app foregrounded (full-power BLE duty cycle).
A backgrounded app drops to POWER_SAVER (1 s scan per 60 s), which makes
mesh reformation after restarts take minutes and scenarios flaky.
`svc power stayon` only applies while charging, so also stretch the
screen timeout as a fallback.
"""
_shell(self.serial, "svc power stayon true")
_shell(self.serial, "settings put system screen_off_timeout 600000")
subprocess.run(
[find_adb(), "-s", self.serial, "shell", "locksettings", "set-disabled", "true"],
check=False, capture_output=True, text=True, timeout=30,
)
_shell(self.serial, "input keyevent KEYCODE_WAKEUP")
_shell(self.serial, "wm dismiss-keyguard")
_shell(self.serial, "input keyevent 82") # dismiss non-secure keyguard
_shell(self.serial, "input swipe 500 1500 500 400") # swipe-up dismiss
def reset_bluetooth(self) -> None:
"""Cycle the BT adapter; clears zombie GATT connections from peer restarts."""
_shell(self.serial, "svc bluetooth disable")
time.sleep(2)
_shell(self.serial, "svc bluetooth enable")
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}")
fixture_dir = f"/data/data/{self.package}/cache/fixtures"
target = f"{fixture_dir}/{fname}"
_shell(
self.serial,
f"run-as {self.package} mkdir -p {fixture_dir} && "
f"cat {tmp} | run-as {self.package} sh -c 'cat > {target}' && rm -f {tmp}",
)
return target
def clear_incoming(self) -> None:
_shell(
self.serial,
f"run-as {self.package} rm -rf cache/files/incoming cache/images/incoming",
)
# -- 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 {self.package} rm -f {RESULTS_DIR}/{cmd_id}.json")
args = [
"am", "broadcast", "-a", self.hook_action,
"-n", self.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):
# `am` stores --el as Long and --ei as Integer; on-device
# readers use getIntExtra, so int extras must go via --ei.
args += ["--ei", 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 {self.package} 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}")
class WatchDevice(Device):
"""Pixel Watch running the com.bitchat.watch debug build.
Same test-hook protocol as the phone; different package/hook, a smaller permission
set (Bluetooth + notifications only), and wake tweaks that skip phone-only keyguard
commands. File-transfer scenarios are not supported on the watch yet (M5 deferred).
"""
def __init__(self, serial: str, alias: str = "watch"):
super().__init__(
serial,
alias,
package=WATCH_APPLICATION_ID,
hook_action=WATCH_TEST_HOOK_ACTION,
hook_component=WATCH_TEST_HOOK_COMPONENT,
permissions=WATCH_PERMISSIONS,
activity_component=f"{WATCH_APPLICATION_ID}/.MainActivity",
)
def wake(self) -> None:
# Keep the screen on while on the charging puck; otherwise Wear shows the
# charging activity on top, our app loses foreground, and the OS freezes the
# process (cached-app freezer), silently hanging test-hook commands.
_shell(self.serial, "settings put global stay_on_while_plugged_in 3")
_shell(self.serial, "svc power stayon true")
_shell(self.serial, "settings put system screen_off_timeout 600000")
_shell(self.serial, "input keyevent KEYCODE_WAKEUP")
# 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
def make_private_media_fixtures(directory: Path, seed: int = 7331) -> dict[str, dict]:
"""Small attachment fixtures covering every private-media UI type."""
directory.mkdir(parents=True, exist_ok=True)
fixtures = {}
rng = random.Random(seed)
for name, mime in (
("voice_note.m4a", "audio/mp4"),
("image_note.jpg", "image/jpeg"),
("document_note.txt", "text/plain"),
):
path = directory / name
data = rng.randbytes(1_024)
path.write_bytes(data)
fixtures[name] = {
"path": path,
"sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data),
"mime": mime,
}
return fixtures
# MARK: - setup
def setup_pair(
a: Device,
b: Device,
apk_a: Path | None,
nickname_a: str,
nickname_b: str,
apk_b: Path | None = None,
) -> None:
if apk_b is None:
apk_b = apk_a
for device, nickname, apk in ((a, nickname_a, apk_a), (b, nickname_b, apk_b)):
device.reset_bluetooth()
device.enable_bluetooth()
if apk is not None:
device.install(apk)
device.clear_app_data()
device.grant_permissions()
device.wake()
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,
recipient: str | None = None,
) -> dict:
"""File transfer A -> B with sha256 integrity verification."""
id_b = whoami(b)["peer_id"]
b.clear_incoming() # avoid name-uniquified collisions across runs
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"] = recipient or id_b
if fixture.get("mime"):
send_kwargs["mime"] = fixture["mime"]
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_private_media(a: Device, b: Device) -> dict:
"""Voice, image, and generic file sends through the private-chat contact ID."""
identity = whoami(b)
noise_public_key = bytes.fromhex(identity["noise_public_key"])
conversation_id = f"contact_{hashlib.sha256(noise_public_key).hexdigest()}"
fixtures = make_private_media_fixtures(
Path(tempfile.mkdtemp(prefix="meshlab-private-media-"))
)
return scenario_file(
a,
b,
fixtures,
private=True,
recipient=conversation_id,
)
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}
# MARK: - session / identity churn scenarios
def _dm_roundtrip(a: Device, b: Device, id_a: str, id_b: str) -> dict:
"""Exchange DMs in both directions with content assertions."""
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_ab, send_ab = recv.result(), send.result()
assert token_ab in recv_ab["content"], recv_ab
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_ba, send_ba = recv.result(), send.result()
assert token_ba in recv_ba["content"], recv_ba
return {"a_to_b": recv_ab, "b_to_a": recv_ba}
def wait_session_established(device: Device, peer_id: str, timeout_s: int = 90) -> dict:
deadline = time.monotonic() + timeout_s
last: dict = {}
while time.monotonic() < deadline:
last = device.cmd_ok("session", peer=peer_id)
if last.get("established"):
return last
time.sleep(2)
raise MeshLabError(
f"[{device.alias}] session with {peer_id} not established within {timeout_s}s (last: {last})"
)
def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None:
"""Wait for rediscovery, then force a direct GATT connection both ways.
Backgrounded devices drop to POWER_SAVER duty cycles (1 s scan per 60 s), so
passively waiting for the mesh to reform takes minutes. The explicit connect
makes restart scenarios deterministic. The addresspeer mapping is learned from
direct-link announces and can lag peer-list discovery after a restart, so the
connect attempt is retried while the peer announces.
"""
wait_for_peer(a, id_b, timeout_s=120)
wait_for_peer(b, id_a, timeout_s=120)
for device, peer, announcer in ((a, id_b, b), (b, id_a, a)):
connected = False
last: dict = {}
for _attempt in range(4):
last = device.cmd("connect", timeout_ms=45_000, peer=peer)
if last.get("status") == "ok" and last.get("direct"):
connected = True
break
# Already acceptable if the mesh formed a direct link on its own.
peers = device.cmd_ok("peers").get("peers", [])
match = next((p for p in peers if p.get("id") == peer), None)
if match and match.get("direct"):
connected = True
break
try:
announcer.cmd_ok("announce")
except MeshLabError:
pass
time.sleep(4)
if not connected:
raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}")
def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict:
"""Retry explicit handshakes; inits can be lost while links settle."""
last: dict = {}
for _ in range(attempts):
last = device.cmd("handshake", timeout_ms=per_attempt_s * 1000, peer=peer_id)
if last.get("status") == "ok":
return last
time.sleep(2)
raise MeshLabError(f"[{device.alias}] handshake with {peer_id} failed after {attempts} attempts (last: {last})")
def scenario_session_recovery(a: Device, b: Device) -> dict:
"""Process death on B: identity must persist, in-memory Noise sessions are lost.
Expected recovery flow: A's DM sent with its stale session is dropped by B
(B has no session and no kick path on pure decrypt failure); B's outgoing DM
auto-triggers a fresh handshake; subsequent DMs must flow both ways.
"""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
a.cmd_ok("handshake", 60_000, peer=id_b)
b.cmd_ok("handshake", 60_000, peer=id_a)
baseline = _dm_roundtrip(a, b, id_a, id_b)
b.force_stop()
b.wake()
b.launch()
b.cmd_ok("start")
b.cmd_ok("set_nickname", name="bob")
id_b_after = whoami(b)["peer_id"]
if id_b_after != id_b:
raise MeshLabError(f"identity changed across process death: {id_b} -> {id_b_after}")
wait_for_peer(a, id_b, timeout_s=120)
wait_for_peer(b, id_a, timeout_s=120)
ensure_direct_link(a, b, id_a, id_b)
# A -> B with A's stale session: B lost its in-memory session; drop expected.
a.cmd_ok("dm_send", 30_000, peer=id_b, content=f"stale-{uuid.uuid4().hex[:8]}")
# B -> A: no session on B, sendPrivateMessage auto-fires the re-handshake.
# The fire-and-forget handshake has no retry, so repeat the trigger, then
# fall back to explicit handshake commands if the auto-path stays stuck.
session_b: dict = {}
for _attempt in range(3):
b.cmd_ok("dm_send", 30_000, peer=id_a, content=f"trigger-{uuid.uuid4().hex[:8]}")
try:
session_b = wait_session_established(b, id_a, timeout_s=20)
break
except MeshLabError:
continue
if not session_b:
force_handshake(b, id_a)
session_b = wait_session_established(b, id_a, timeout_s=30)
session_a = wait_session_established(a, id_b)
recovered = _dm_roundtrip(a, b, id_a, id_b)
return {
"identity_preserved": True,
"baseline": baseline,
"session_a": session_a,
"session_b": session_b,
"recovered": recovered,
}
def scenario_identity_reset(a: Device, b: Device) -> dict:
"""pm clear on B mid-session: new identity, rediscovery, fresh handshake and DMs."""
id_a = whoami(a)["peer_id"]
id_b_old = whoami(b)["peer_id"]
a.cmd_ok("handshake", 60_000, peer=id_b_old)
b.cmd_ok("handshake", 60_000, peer=id_a)
_dm_roundtrip(a, b, id_a, id_b_old)
b.clear_app_data()
b.grant_permissions()
b.wake()
b.launch()
b.cmd_ok("start")
b.cmd_ok("set_nickname", name="bob")
id_b_new = whoami(b)["peer_id"]
if id_b_new == id_b_old:
raise MeshLabError("identity survived pm clear")
wait_for_peer(a, id_b_new, timeout_s=180)
ensure_direct_link(a, b, id_a, id_b_new)
force_handshake(a, id_b_new)
force_handshake(b, id_a)
recovered = _dm_roundtrip(a, b, id_a, id_b_new)
# Inspect how A treats the dead peer's stale session (evidence, not an assertion).
stale = a.cmd("session", peer=id_b_old)
return {
"old_peer_id": id_b_old,
"new_peer_id": id_b_new,
"identity_changed": True,
"recovered": recovered,
"stale_session_on_a": stale,
}
def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> dict:
"""Oversized broadcast file must be rejected sender-side (>256 fragments)."""
fixture = fixtures["medium_512k.bin"]
remote = a.push_fixture(fixture["path"])
send = a.cmd("file_send", timeout_ms=60_000, path=remote)
rejected = send.get("status") == "error" and "rejected" in send.get("error", "")
if not rejected:
raise MeshLabError(f"expected sender-side rejection, got: {send}")
# Receiver must not see any file appear.
recv = b.cmd("file_recv", timeout_ms=15_000, name_contains="medium_512k")
if recv.get("status") == "ok":
raise MeshLabError(f"receiver unexpectedly saved an oversized file: {recv}")
return {"send": send, "receiver_saw_file": False}
SCENARIOS = {
"dm": scenario_dm,
"broadcast": scenario_broadcast,
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
# the small fixture is end-to-end receivable.
"file": lambda a, b: scenario_file(
a, b,
make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]),
),
"file_oversize": lambda a, b: scenario_file_oversize(
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,
),
"media_private": scenario_private_media,
"raw": scenario_raw,
"session_recovery": scenario_session_recovery,
"identity_reset": scenario_identity_reset,
}
# Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends,
# the watch must receive with matching digests).
WATCH_SCENARIOS = ["dm", "broadcast", "raw", "file", "file_private", "session_recovery", "identity_reset"]
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:
supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS)
if name == "all":
results = {}
failures = []
for n in supported:
sub = run_scenario(n, a, b, out)
results[n] = sub.get("results", {"error": sub.get("error", "unknown")})
if sub["status"] != "pass":
failures.append(n)
evidence["results"] = results
if failures:
raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}")
elif name not in supported:
raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'")
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")
setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
setup.add_argument("--apk", type=Path, default=None)
setup.add_argument("--watch-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")
scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
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 string extra (repeatable)")
raw.add_argument("--extra-int", action="append", default=[], help="key=value int extra (repeatable)")
raw.add_argument("--timeout-ms", type=int, default=60_000)
return parser
def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]:
"""Device A is always the phone; device B is a watch when --serial-watch is given."""
a = Device(args.serial_a, "alpha")
if getattr(args, "serial_watch", None):
return a, WatchDevice(args.serial_watch)
if not getattr(args, "serial_b", None):
raise MeshLabError("either --serial-b or --serial-watch is required")
return a, Device(args.serial_b, "beta")
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "setup":
a, b = _resolve_devices(args)
nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b
setup_pair(
a, b, args.apk, args.nickname_a, nickname_b,
apk_b=args.watch_apk if isinstance(b, WatchDevice) else None,
)
print(json.dumps({"status": "ok", "step": "setup"}))
elif args.command == "scenario":
a, b = _resolve_devices(args)
evidence = run_scenario(args.name, a, b, 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] = {}
extras: dict[str, object] = {}
for item in args.extra:
key, _, value = item.partition("=")
extras[key] = value
for item in args.extra_int:
key, _, value = item.partition("=")
extras[key] = int(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())

193
wear/build.gradle.kts Normal file
View File

@ -0,0 +1,193 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.bitchat.watch"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "com.bitchat.watch"
minSdk = 33 // Wear OS 4 (Pixel Watch 1+): the S+ Bluetooth permissions the app
// declares only exist from API 31, and API 30 would additionally require location
// for BLE scan results, which the app deliberately refuses.
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "0.1.0"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Sign with the debug key so release builds can be installed over the
// debug app during development (same signature = seamless upgrade).
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
abortOnError = false
checkReleaseBuilds = false
}
}
// Shared bitchat protocol stack: compiled from :app sources in place (never moved/copied by hand).
// AGP's source directory sets no longer support include/exclude filters, so a Sync task
// materializes a filtered mirror into build/sharedSrc and that directory is added as a source
// root. The app sources remain the single source of truth; extend the include list below (don't
// copy files into wear/src) when the compiler reveals a missing transitive dependency.
// Deliberately excluded: ui (except the DebugSettingsManager the mesh layer references),
// onboarding, nostr (except pure-Kotlin Bech32), net, geohash, wifi-aware, hotspot, voice
// features, and the phone's foreground service.
val sharedSourceIncludes = listOf(
"com/bitchat/android/protocol/**",
"com/bitchat/android/noise/**",
"com/bitchat/android/crypto/**",
"com/bitchat/android/identity/**",
"com/bitchat/android/mesh/**",
"com/bitchat/android/model/**",
"com/bitchat/android/sync/**",
"com/bitchat/android/favorites/**",
"com/bitchat/android/services/AppStateStore.kt",
"com/bitchat/android/services/ContactDirectory.kt",
"com/bitchat/android/services/ContactIdentityResolver.kt",
"com/bitchat/android/services/PrivateMessageArrivalOrder.kt",
"com/bitchat/android/services/SeenMessageStore.kt",
"com/bitchat/android/services/VerificationService.kt",
"com/bitchat/android/services/meshgraph/**",
"com/bitchat/android/service/TransportBridgeService.kt",
"com/bitchat/android/nostr/Bech32.kt",
"com/bitchat/android/nostr/GeohashAliasRegistry.kt",
"com/bitchat/android/features/file/FileUtils.kt",
"com/bitchat/android/features/voice/**",
"com/bitchat/android/ui/debug/DebugSettingsManager.kt",
"com/bitchat/android/ui/debug/DebugPreferenceManager.kt",
"com/bitchat/android/ui/NotificationTextUtils.kt",
"com/bitchat/android/util/AppConstants.kt",
"com/bitchat/android/util/ByteArrayExtensions.kt",
"com/bitchat/android/util/ByteArrayWrapper.kt",
"com/bitchat/android/util/BinaryEncodingUtils.kt",
)
val sharedSourceExcludes = listOf(
"com/bitchat/android/model/FileSharingManager.kt",
// Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service
// (MeshCore-style) in M2 instead of reusing these.
"com/bitchat/android/mesh/BluetoothMeshService.kt",
"com/bitchat/android/mesh/UnifiedMeshService.kt",
// Phone permission policy additionally requires location (legacy BLE); the watch app
// declares Bluetooth permissions only, so it ships its own same-FQN variant in
// wear/src/main (Bluetooth-only check).
"com/bitchat/android/mesh/BluetoothPermissionManager.kt",
)
val syncSharedAppSources = tasks.register<Sync>("syncSharedAppSources") {
from("../app/src/main/java") {
include(sharedSourceIncludes)
exclude(sharedSourceExcludes)
}
into(layout.buildDirectory.dir("sharedSrc"))
}
// The app's own unit tests for the shared packages also run in the wear module, so shared
// behavior is continuously verified on both targets. Includes the app's JVM shims for
// android.util.Log/Base64 (app/src/test/kotlin/android) that the shared code needs on the JVM.
val syncSharedAppTests = tasks.register<Sync>("syncSharedAppTests") {
from("../app/src/test/java") {
include(
"com/bitchat/android/protocol/**",
"com/bitchat/android/crypto/**",
"com/bitchat/android/mesh/**",
)
}
from("../app/src/test/kotlin") {
include(
"android/**",
"com/bitchat/android/mesh/**",
"com/bitchat/FileTransferTest.kt",
)
}
into(layout.buildDirectory.dir("sharedTestSrc"))
}
android {
sourceSets {
getByName("main") {
java.srcDir("build/sharedSrc")
kotlin.srcDir("build/sharedSrc")
}
getByName("test") {
java.srcDir("build/sharedTestSrc")
kotlin.srcDir("build/sharedTestSrc")
}
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(syncSharedAppSources)
}
tasks.matching { it.name.contains("UnitTest", ignoreCase = true) }.configureEach {
dependsOn(syncSharedAppTests)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
// Wear Compose
implementation(libs.androidx.wear.compose.foundation)
implementation(libs.androidx.wear.compose.material3)
implementation(libs.androidx.wear.tooling.preview)
implementation(libs.androidx.compose.material.icons.extended)
// Lifecycle
implementation(libs.bundles.lifecycle)
implementation(libs.androidx.lifecycle.process)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Cryptography (shared Noise/encryption stack)
implementation(libs.bouncycastle.bcprov)
// JSON (BitchatMessage model)
implementation(libs.gson)
// Security preferences (Noise identity persistence)
implementation(libs.androidx.security.crypto)
// Testing
testImplementation(libs.bundles.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
}

13
wear/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,13 @@
# Gson reflection targets in the shared bitchat sources (persisted state payloads).
-keep class com.bitchat.android.favorites.** { *; }
-keep class com.bitchat.android.services.SeenMessageStore$* { *; }
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# Kotlin metadata needed by reflection-based serialization.
-keepattributes Signature, InnerClasses, EnclosingMethod
# Tink references JSR-305 annotations not present on Android.
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.concurrent.GuardedBy

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name="com.bitchat.watch.testhook.WearTestHookReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.bitchat.watch.TEST_HOOK" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,389 @@
package com.bitchat.watch.testhook
import android.content.Context
import android.content.Intent
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.service.WearMeshForegroundService
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Headless engine behind [WearTestHookReceiver]. Drives [WearMeshService] and observes
* [AppStateStore] flows. Command set mirrors the phone's TestHookDriver (minus file transfer,
* which is deferred on the watch).
*/
object WearTestHookDriver {
private const val TAG = WearTestHookReceiver.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
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"))
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
"dm_recv" -> dmRecv(context, intent)
"msg_recv" -> msgRecv(context, intent)
"raw_send" -> rawSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"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 {
val mesh = mesh(context)
try {
context.startForegroundService(Intent(context, WearMeshForegroundService::class.java))
} catch (e: Exception) {
// Background FGS starts are restricted (API 31+); mesh_lab launches the app first,
// but fall back to a service-less mesh start so the command still works.
Log.w(TAG, "foreground service start failed, starting mesh directly: ${e.message}")
}
mesh.startServices()
return ok("start").put("peer_id", mesh.myPeerID)
}
private fun stop(context: Context): JSONObject {
try {
WearMeshService.peek()?.stopServices()
} catch (e: Exception) {
Log.w(TAG, "stopServices failed: ${e.message}")
}
context.stopService(Intent(context, WearMeshForegroundService::class.java))
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", mesh.nickname)
}
private fun setNickname(context: Context, name: String): JSONObject {
mesh(context).setNickname(name)
AppStateStore.setNickname(name)
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 mesh = WearMeshService.peek() ?: return err("connect", "mesh service not running")
// The address↔peer mapping is learned from direct-link announces and can lag
// peer-list discovery (especially right after a restart); poll while announcing.
val deadline = System.currentTimeMillis() + timeoutMs / 2
var address: String? = mesh.getDeviceAddressForPeer(peerID)
while (address == null && System.currentTimeMillis() < deadline) {
mesh.sendBroadcastAnnounce()
delay(1_000)
address = mesh.getDeviceAddressForPeer(peerID)
}
if (address == null) {
return err("connect", "no device address known for peer $peerID (scan first)")
}
val accepted = mesh.connectToPeer(peerID)
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): JSONObject {
mesh(context).sendChannelMessage(content, emptyList(), null)
return ok("broadcast_msg").put("content", content)
}
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.sendPrivateMessageWithId(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 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))
}
val found = withTimeoutOrNull(timeoutMs) {
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("msg_id", found.id)
}
// 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: - File transfer (receive only; the watch does not send files via test hook)
private suspend fun fileRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L)
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",
java.security.MessageDigest.getInstance("SHA-256")
.digest(candidate.readBytes()).toHex()
)
}
}
delay(250)
}
return err("file_recv", "timeout after ${timeoutMs}ms")
}
// MARK: - State
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", mesh.nickname)
.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): WearMeshService = WearMeshService.getOrCreate(context)
private fun peerInfosJson(mesh: WearMeshService, 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 ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun hexToBytes(hex: String): ByteArray? {
val clean = hex.replace(" ", "")
if (clean.length % 2 != 0) return null
return try {
ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
} catch (e: Exception) {
null
}
}
}

View File

@ -0,0 +1,61 @@
package com.bitchat.watch.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 for the watch app (debug builds only). Mirrors the phone's protocol:
*
* adb shell am broadcast -a com.bitchat.watch.TEST_HOOK \
* --es cmd <command> --es id <cmd-id> [command extras...]
*
* Result is written to cache/testhook/results/<id>.json (readable via run-as com.bitchat.watch)
* and logged under tag TestHook.
*/
class WearTestHookReceiver : BroadcastReceiver() {
companion object {
const val TAG = "TestHook"
const val ACTION = "com.bitchat.watch.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) {
WearTestHookDriver.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()
pendingResult.finish()
}
}

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.type.watch" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<!-- Bluetooth mesh only. Deliberately NO android.permission.INTERNET and no location. -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".BitchatWatchApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="true" />
<service
android:name=".service.WearMeshForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/BitchatLaunchTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,45 @@
package com.bitchat.android.mesh
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
/**
* Wear variant of the phone's BluetoothPermissionManager.
*
* The phone version additionally requires ACCESS_FINE/COARSE_LOCATION (legacy BLE scanning
* behavior on older phones). The watch app deliberately declares no location permissions
* on Wear OS, BLUETOOTH_SCAN with the `neverForLocation` flag is sufficient so only the
* Bluetooth runtime permissions are checked here.
*
* Same fully-qualified name as the phone class, which is excluded from the wear shared-source
* sync (see wear/build.gradle.kts), so there is exactly one definition in this compilation.
*/
class BluetoothPermissionManager(private val context: Context) {
fun hasBluetoothPermissions(): Boolean {
val permissions = mutableListOf<String>()
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
permissions.addAll(
listOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN
)
)
} else {
permissions.addAll(
listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
)
)
}
return permissions.all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
}

View File

@ -0,0 +1,17 @@
package com.bitchat.android.service
/**
* Wear shim for the phone's MeshServiceHolder.
*
* The shared `DebugSettingsManager` (compiled from app sources) references this holder only to
* toggle BLE transport from the phone's debug UI, which does not exist on the watch. The real
* holder is typed against `BluetoothMeshService`, which the watch deliberately does not include.
*/
object MeshServiceHolder {
interface BleToggle {
fun setBleTransportEnabled(enabled: Boolean)
}
val meshService: BleToggle? = null
}

View File

@ -0,0 +1,11 @@
package com.bitchat.android.wifiaware
/**
* Wear shim for the phone's WifiAwareController.
*
* Referenced only by the shared `DebugSettingsManager` debug-UI toggle. Wi-Fi Aware is out of
* scope for the watch (Bluetooth mesh only), so this is a no-op.
*/
object WifiAwareController {
fun setEnabled(value: Boolean) = Unit
}

View File

@ -0,0 +1,13 @@
package com.bitchat.watch
import android.app.Application
import com.bitchat.android.mesh.PowerManager
import com.bitchat.watch.notification.WearNotificationCoordinator
class BitchatWatchApplication : Application() {
override fun onCreate() {
super.onCreate()
PowerManager.getInstance(applicationContext)
WearNotificationCoordinator.getInstance(applicationContext)
}
}

View File

@ -0,0 +1,357 @@
package com.bitchat.watch
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.TextButton
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.notification.WearNotificationCoordinator
import com.bitchat.watch.service.WearMeshForegroundService
import com.bitchat.watch.ui.ChatScreen
import com.bitchat.watch.ui.DmScreen
import com.bitchat.watch.ui.NicknameSetupScreen
import com.bitchat.watch.ui.PeopleScreen
import com.bitchat.watch.ui.WearChatState
import com.bitchat.watch.ui.sendPrivateMessage
import com.bitchat.watch.ui.sendPublicMessage
import com.bitchat.watch.ui.theme.BitchatWearTheme
sealed interface WearScreen {
data object Chat : WearScreen
data object People : WearScreen
data object Nickname : WearScreen
data class Dm(val peerID: String) : WearScreen
data class TextInput(val peerID: String?) : WearScreen
}
class MainActivity : ComponentActivity() {
private var hasPermissions by mutableStateOf(false)
private var bluetoothEnabled by mutableStateOf(false)
private var nicknameChosen by mutableStateOf(false)
private var notificationsGranted by mutableStateOf(false)
private var notificationPromptDismissed by mutableStateOf(false)
private var pendingDmPeer by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE)
.getBoolean("nickname_chosen", false)
pendingDmPeer = privateMessagePeerFromIntent(intent)
refreshState()
setContent {
BitchatWearTheme {
when {
!hasPermissions -> PermissionRequestScreen(onGranted = { refreshState() })
!bluetoothEnabled -> BluetoothEnableScreen(onEnabled = { refreshState() })
!nicknameChosen -> NicknameSetupScreen(
initialNickname = WearMeshService.getOrCreate(applicationContext).nickname
) { name ->
WearMeshService.getOrCreate(applicationContext).setNickname(name)
getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE)
.edit().putBoolean("nickname_chosen", true).apply()
nicknameChosen = true
}
!notificationsGranted && !notificationPromptDismissed ->
NotificationPermissionScreen(
onResult = { granted ->
notificationPromptDismissed = !granted
refreshState()
},
onSkip = { notificationPromptDismissed = true }
)
else -> WearNavHost(
openDmPeer = pendingDmPeer,
onOpenDmHandled = { pendingDmPeer = null }
)
}
}
}
}
override fun onResume() {
super.onResume()
WearChatState.setAppInForeground(true)
WearChatState.openDmPeer?.let { peerID ->
WearChatState.openDm(peerID)
WearNotificationCoordinator.getInstance(applicationContext).clearConversation(peerID)
}
refreshState()
}
override fun onPause() {
WearChatState.setAppInForeground(false)
super.onPause()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
privateMessagePeerFromIntent(intent)?.let { pendingDmPeer = it }
}
private fun refreshState() {
hasPermissions = requiredPermissions().all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
notificationsGranted = notificationPermissionGranted()
val adapter = getSystemService(BluetoothManager::class.java)?.adapter
bluetoothEnabled = adapter?.isEnabled == true
if (hasPermissions && bluetoothEnabled) {
startMeshService()
}
}
private fun startMeshService() {
WearMeshService.getOrCreate(applicationContext)
startForegroundService(Intent(this, WearMeshForegroundService::class.java))
}
private fun privateMessagePeerFromIntent(intent: Intent?): String? {
if (intent?.getBooleanExtra(WearNotificationCoordinator.EXTRA_OPEN_DM, false) != true) {
return null
}
return intent.getStringExtra(WearNotificationCoordinator.EXTRA_PEER_ID)
?.takeIf { it.isNotBlank() }
}
private fun notificationPermissionGranted(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
companion object {
fun requiredPermissions(): List<String> = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
add(Manifest.permission.BLUETOOTH_ADVERTISE)
}
}
}
}
@Composable
fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) {
var screen by remember { mutableStateOf<WearScreen>(WearScreen.Chat) }
val backStack = remember { mutableStateListOf<WearScreen>() }
fun navigate(to: WearScreen) {
backStack.add(screen)
screen = to
}
fun goBack(): Boolean {
val previous = backStack.removeLastOrNull()
return if (previous != null) {
screen = previous
true
} else false
}
BackHandler(enabled = backStack.isNotEmpty()) { goBack() }
LaunchedEffect(openDmPeer) {
openDmPeer?.let { peerID ->
backStack.clear()
screen = WearScreen.Dm(peerID)
onOpenDmHandled()
}
}
AnimatedContent(
targetState = screen,
transitionSpec = {
fadeIn(tween(com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS)) togetherWith
fadeOut(tween(com.bitchat.watch.ui.theme.BitchatMotion.QUICK_MS))
},
label = "screenTransition"
) { current ->
when (current) {
is WearScreen.Chat -> ChatScreen(
onOpenPeople = { navigate(WearScreen.People) },
onOpenTextInput = { navigate(WearScreen.TextInput(null)) }
)
is WearScreen.People -> PeopleScreen(
onOpenDm = { navigate(WearScreen.Dm(it)) },
onEditNickname = { navigate(WearScreen.Nickname) }
)
is WearScreen.Nickname -> {
val mesh = WearMeshService.peek()
NicknameSetupScreen(
initialNickname = mesh?.nickname ?: "",
title = "You",
subtitle = "How nearby peers see you",
confirmLabel = "Save",
onConfirm = { name ->
mesh?.setNickname(name)
goBack()
}
)
}
is WearScreen.Dm -> DmScreen(
peerID = current.peerID,
onOpenTextInput = { navigate(WearScreen.TextInput(current.peerID)) }
)
is WearScreen.TextInput -> {
val mesh = WearMeshService.peek()
val sendScope = androidx.compose.runtime.rememberCoroutineScope()
com.bitchat.watch.ui.TextInputScreen(
onSend = { text ->
mesh?.let { m ->
if (current.peerID == null) {
sendPublicMessage(m, text)
} else {
val nick = m.getPeerNickname(current.peerID) ?: current.peerID
sendPrivateMessage(m, current.peerID, nick, text, sendScope)
}
}
goBack()
}
)
}
}
}
}
@Composable
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onResult(granted) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp)
)
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
}
) {
Text("Enable")
}
TextButton(onClick = onSkip) {
Text("Not now")
}
}
}
@Composable
fun PermissionRequestScreen(onGranted: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { onGranted() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
)
Button(onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}) {
Text("Grant access")
}
}
}
@Composable
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { onEnabled() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp)
) {
Text("Turn on")
}
}
}

View File

@ -0,0 +1,401 @@
package com.bitchat.watch.mesh
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.mesh.BluetoothConnectionManager
import com.bitchat.android.mesh.BluetoothConnectionManagerDelegate
import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy
import com.bitchat.android.mesh.MeshCore
import com.bitchat.android.mesh.MeshTransport
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Watch mesh service: composes the shared BLE transport (BluetoothConnectionManager) with the
* shared mesh coordinator (MeshCore), mirroring how the phone's Wi-Fi Aware service is built.
* Bluetooth mesh only no internet, no other transports.
*/
class WearMeshService private constructor(private val context: Context) {
companion object {
private const val TAG = "WearMeshService"
private val MAX_TTL: UByte = AppConstants.MESSAGE_TTL_HOPS
private val PEER_DISCONNECT_GRACE_MS: Long = AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS
@Volatile
private var instance: WearMeshService? = null
fun getOrCreate(context: Context): WearMeshService {
return instance ?: synchronized(this) {
instance ?: WearMeshService(context.applicationContext).also { instance = it }
}
}
fun peek(): WearMeshService? = instance
}
val encryptionService = EncryptionService(context)
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val bleTransport = BleTransport()
private val meshCore: MeshCore
private var connectionManager: BluetoothConnectionManager
@Volatile
var nickname: String = loadNickname()
private set
@Volatile
private var isActive = false
/** UI hook fired for every incoming private message (after storing). */
var onPrivateMessage: ((com.bitchat.android.model.BitchatMessage) -> Unit)? = null
init {
meshCore = MeshCore(
context = context.applicationContext,
scope = serviceScope,
transport = bleTransport,
encryptionService = encryptionService,
myPeerID = myPeerID,
maxTtl = MAX_TTL,
sharedGossipManager = null,
gossipConfigProvider = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = 500
override fun gcsMaxBytes(): Int = 400
override fun gcsTargetFpr(): Double = 0.01
},
hooks = MeshCore.Hooks(
onMessageReceived = { message -> handleMessageReceived(message) },
onAnnounceProcessed = { routed, _ ->
// Mirror the phone's BluetoothMeshService: learn the direct BLE
// address↔peerID mapping from direct-link announcements.
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { obs ->
val observed = connectionManager.observePeerIfCurrent(
obs.relayAddress,
obs.ingressLinkID,
obs.peerID
)
if (observed) {
meshCore.setDirectConnection(obs.peerID, true)
try {
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(obs.peerID, 1_000)
} catch (_: Exception) { }
}
}
routed.peerID?.let { pid ->
maybeAutoHandshake(pid)
try {
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
} catch (_: Exception) { }
}
},
announcementNicknameProvider = { nickname },
leavePayloadProvider = { nickname.toByteArray(Charsets.UTF_8) }
)
)
connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager)
bleTransport.connectionManager = connectionManager
wireBluetoothDelegate()
}
private inner class BleTransport : MeshTransport {
lateinit var connectionManager: BluetoothConnectionManager
override val id: String = "BLE"
override fun broadcastPacket(routed: RoutedPacket): Boolean =
connectionManager.broadcastPacket(routed)
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean =
connectionManager.sendPacketToPeer(peerID, packet)
override fun sendPacketToLink(
relayAddress: String,
ingressLinkID: String,
packet: BitchatPacket
): Boolean = connectionManager.sendPacketToLink(relayAddress, ingressLinkID, packet)
override fun cancelTransfer(transferId: String): Boolean =
connectionManager.cancelTransfer(transferId)
override fun getDeviceAddressForPeer(peerID: String): String? =
connectionManager.addressPeerMap.entries.firstOrNull { it.value == peerID }?.key
override fun getDeviceAddressToPeerMapping(): Map<String, String> =
connectionManager.addressPeerMap.toMap()
override fun getTransportDebugInfo(): String = connectionManager.getDebugInfo()
}
private fun wireBluetoothDelegate() {
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: BluetoothDevice?,
ingressLinkID: String
) {
try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packet = packet,
fromPeerID = peerID,
fromNickname = null,
fromDeviceAddress = device?.address,
myPeerID = myPeerID
)
} catch (_: Exception) { }
meshCore.processIncoming(packet, peerID, device?.address, ingressLinkID)
}
override fun onDeviceConnected(device: BluetoothDevice) {
Log.i(TAG, "Device connected: ${device.address}")
serviceScope.launch {
delay(200)
meshCore.sendBroadcastAnnounce()
}
}
override fun onDeviceDisconnected(
device: BluetoothDevice,
linkID: String?,
peerID: String?
) {
Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)")
try { meshCore.refreshPeerList() } catch (_: Exception) { }
if (peerID != null) {
meshCore.setDirectConnection(peerID, false)
val deviceAddress = device.address
serviceScope.launch {
delay(PEER_DISCONNECT_GRACE_MS)
try {
val linkBack =
connectionManager.addressPeerMap.containsKey(deviceAddress) ||
connectionManager.addressPeerMap.containsValue(peerID)
if (!linkBack) {
Log.i(TAG, "Peer $peerID did not return after disconnect; removing")
meshCore.removePeer(peerID)
}
} catch (e: Exception) {
Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}")
}
}
}
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
connectionManager.addressPeerMap[deviceAddress]?.let { peerID ->
meshCore.updatePeerRSSI(peerID, rssi)
}
}
}
}
/**
* Proactively establish a Noise session with peers we have no session for (throttled to
* one attempt per peer per 60 s). Peers may hold a stale session after we restart the
* protocol has no decrypt-failure kick path, so our fresh handshake replaces it and
* restores encrypted DM/file delivery.
*/
private val handshakeAttempts = java.util.concurrent.ConcurrentHashMap<String, Long>()
private fun maybeAutoHandshake(peerID: String) {
if (peerID == myPeerID || hasEstablishedSession(peerID)) return
val now = System.currentTimeMillis()
val last = handshakeAttempts[peerID] ?: 0L
if (now - last < 60_000) return
handshakeAttempts[peerID] = now
serviceScope.launch {
delay(1_500)
if (!hasEstablishedSession(peerID)) {
try {
Log.d(TAG, "Auto-initiating Noise handshake with ${peerID.take(8)}")
initiateNoiseHandshake(peerID)
} catch (_: Exception) { }
}
}
}
private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) {
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: return
AppStateStore.addPrivateMessage(peer, message)
try { onPrivateMessage?.invoke(message) } catch (_: Exception) { }
}
message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message)
else -> AppStateStore.addPublicMessage(message)
}
} catch (_: Exception) { }
}
fun startServices() {
if (isActive) {
Log.w(TAG, "Mesh already active, ignoring duplicate start")
return
}
if (!connectionManager.isReusable()) {
// A previous stopServices() cancelled the manager's coroutine scope; the shared
// API marks such managers single-use, so build a fresh one instead of starting
// a zombie mesh that reports active while scanning nothing.
Log.i(TAG, "Recreating BluetoothConnectionManager after terminal stop")
connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager)
bleTransport.connectionManager = connectionManager
wireBluetoothDelegate()
}
val started = connectionManager.startServices()
if (started) {
isActive = true
meshCore.startCore()
serviceScope.launch {
delay(500)
meshCore.sendBroadcastAnnounce()
}
Log.i(TAG, "Mesh services started (peerID: $myPeerID)")
} else {
Log.e(TAG, "Failed to start Bluetooth services (permissions? BT off?)")
}
}
fun stopServices() {
if (!isActive) return
isActive = false
meshCore.stopCore()
connectionManager.stopServices()
Log.i(TAG, "Mesh services stopped")
}
fun isRunning(): Boolean = isActive
fun setNickname(name: String) {
val trimmed = name.trim().take(32)
if (trimmed.isEmpty() || trimmed == nickname) return
nickname = trimmed
saveNickname(trimmed)
if (isActive) {
serviceScope.launch { meshCore.sendBroadcastAnnounce() }
}
}
fun sendMessage(content: String, mentions: List<String> = emptyList()) {
meshCore.sendMessage(content, mentions, null)
}
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String) {
meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname)
}
fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID)
fun hasEstablishedSession(peerID: String): Boolean = meshCore.hasEstablishedSession(peerID)
fun getSessionState(peerID: String) = meshCore.getSessionState(peerID)
fun getPeerInfo(peerID: String) = meshCore.getPeerInfo(peerID)
fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint()
fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey()
fun sendBroadcastAnnounce() = meshCore.sendBroadcastAnnounce()
fun sendChannelMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
meshCore.sendMessage(content, mentions, channel)
}
fun sendPrivateMessageWithId(
content: String,
recipientPeerID: String,
recipientNickname: String,
messageID: String?
) {
meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
}
fun getDeviceAddressForPeer(peerID: String): String? = meshCore.getDeviceAddressForPeer(peerID)
fun getDeviceAddressToPeerMapping(): Map<String, String> = meshCore.getDeviceAddressToPeerMapping()
fun connectToPeer(peerID: String): Boolean {
val address = getDeviceAddressForPeer(peerID) ?: return false
return connectionManager.connectToAddress(address)
}
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
meshCore.sendFileBroadcast(file)
}
/**
* Noise-encrypted private file transfer with session/prep retry (mirrors the phone's
* dispatchFileSend): ensures an established session, then retries transient
* preparation states (AwaitingPeerState/NeedsHandshake) before giving up.
*/
fun sendFilePrivateEncrypted(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
serviceScope.launch {
val sessionDeadline = System.currentTimeMillis() + 15_000
while (!hasEstablishedSession(recipientPeerID) && System.currentTimeMillis() < sessionDeadline) {
try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { }
delay(500)
}
val transferId = com.bitchat.android.mesh.MeshPacketUtils.sha256Hex(
file.encode() ?: return@launch
)
val prepDeadline = System.currentTimeMillis() + 30_000
while (System.currentTimeMillis() < prepDeadline) {
when (val prep = meshCore.prepareFilePrivate(recipientPeerID, file, transferId, allowLegacyFallback = false)) {
is com.bitchat.android.mesh.PrivateMediaPreparation.Ready -> {
prep.transfer.commit()
return@launch
}
com.bitchat.android.mesh.PrivateMediaPreparation.AwaitingPeerState,
com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake -> {
if (prep == com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake) {
try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { }
}
delay(500)
}
else -> {
Log.w(TAG, "private voice note preparation failed: $prep")
return@launch
}
}
}
Log.w(TAG, "private voice note preparation timed out")
}
}
fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID)
fun getPeerNicknames(): Map<String, String> = meshCore.getPeerNicknames()
fun getPeerRSSI(): Map<String, Int> = meshCore.getPeerRSSI()
fun getPeerNickname(peerID: String): String? = meshCore.getPeerNickname(peerID)
fun getDebugStatus(): String = meshCore.getDebugStatus(
transportInfo = connectionManager.getDebugInfo(),
deviceMap = connectionManager.addressPeerMap.toMap(),
title = "Wear BLE Mesh Debug Status"
)
private fun prefs() = context.getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE)
private fun loadNickname(): String =
prefs().getString("nickname", null) ?: "watch-${myPeerID.take(4)}"
private fun saveNickname(name: String) {
prefs().edit().putString("nickname", name).apply()
}
}

View File

@ -0,0 +1,230 @@
package com.bitchat.watch.notification
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.content.ContextCompat
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.NotificationTextUtils
import com.bitchat.watch.MainActivity
import com.bitchat.watch.R
import com.bitchat.watch.ui.WearChatState
import java.util.concurrent.ConcurrentHashMap
/**
* Process-wide owner for local Wear notifications.
*
* Mesh delivery must not depend on an Activity being alive, so the foreground service invokes this
* coordinator directly. UI state is consulted only to suppress an alert for the exact DM that is
* currently visible while the app is resumed.
*/
class WearNotificationCoordinator private constructor(context: Context) {
companion object {
const val EXTRA_OPEN_DM = "com.bitchat.watch.extra.OPEN_DM"
const val EXTRA_PEER_ID = "com.bitchat.watch.extra.PEER_ID"
private const val MESSAGE_CHANNEL_ID = "bitchat_watch_messages"
private const val GROUP_KEY_DM = "bitchat_watch_dm_group"
private const val SUMMARY_NOTIFICATION_ID = 2
private const val CONVERSATION_NOTIFICATION_ID_BASE = 10_000
@Volatile
private var instance: WearNotificationCoordinator? = null
fun getInstance(context: Context): WearNotificationCoordinator {
return instance ?: synchronized(this) {
instance ?: WearNotificationCoordinator(context.applicationContext).also {
instance = it
}
}
}
}
private data class PendingMessage(
val senderNickname: String,
val preview: String,
val timestamp: Long
)
private val appContext = context.applicationContext
private val notificationManager = NotificationManagerCompat.from(appContext)
private val pendingMessages = ConcurrentHashMap<String, MutableList<PendingMessage>>()
init {
createMessageChannel()
}
@Synchronized
fun onPrivateMessage(
message: BitchatMessage,
senderPeerID: String,
senderNickname: String
) {
val shouldNotify = WearNotificationPolicy.shouldNotifyPrivateMessage(
senderPeerID = senderPeerID,
senderIsSystem = message.sender == "system",
appInForeground = WearChatState.appInForeground,
openDmPeer = WearChatState.openDmPeer
)
if (!shouldNotify || !canPostNotifications()) return
pendingMessages.getOrPut(senderPeerID) { mutableListOf() }.add(
PendingMessage(
senderNickname = senderNickname,
preview = NotificationTextUtils.buildPrivateMessagePreview(message),
timestamp = message.timestamp.time
)
)
postConversationNotification(senderPeerID)
if (pendingMessages.size > 1) {
postSummaryNotification()
}
}
@Synchronized
fun clearConversation(peerID: String) {
pendingMessages.remove(peerID)
notificationManager.cancel(conversationNotificationId(peerID))
updateSummaryNotification()
}
private fun postConversationNotification(peerID: String) {
val messages = pendingMessages[peerID] ?: return
val latest = messages.lastOrNull() ?: return
val sender = Person.Builder()
.setName(latest.senderNickname)
.setKey(peerID)
.build()
val user = Person.Builder()
.setName(appContext.getString(R.string.app_name))
.build()
val style = NotificationCompat.MessagingStyle(user)
.setConversationTitle(latest.senderNickname)
messages.takeLast(5).forEach { pending ->
style.addMessage(pending.preview, pending.timestamp, sender)
}
val contentIntent = PendingIntent.getActivity(
appContext,
conversationNotificationId(peerID),
Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(EXTRA_OPEN_DM, true)
putExtra(EXTRA_PEER_ID, peerID)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val publicVersion = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(appContext.getString(R.string.notification_new_private_message))
.setContentText(appContext.getString(R.string.notification_private_message_hidden))
.build()
val notification = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(latest.senderNickname)
.setContentText(latest.preview)
.setContentIntent(contentIntent)
.setStyle(style)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.setAutoCancel(true)
.setOnlyAlertOnce(messages.size > 1)
.setWhen(latest.timestamp)
.setShowWhen(true)
.setGroup(GROUP_KEY_DM)
.addPerson(sender)
.build()
try {
notificationManager.notify(conversationNotificationId(peerID), notification)
} catch (_: SecurityException) {
// Permission can be revoked between the preflight check and notify().
}
}
private fun postSummaryNotification() {
val totalMessages = pendingMessages.values.sumOf { it.size }
val contentIntent = PendingIntent.getActivity(
appContext,
SUMMARY_NOTIFICATION_ID,
Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val summary = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(appContext.getString(R.string.app_name))
.setContentText(
appContext.resources.getQuantityString(
R.plurals.notification_private_message_summary,
totalMessages,
totalMessages
)
)
.setContentIntent(contentIntent)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY_DM)
.setGroupSummary(true)
.build()
try {
notificationManager.notify(SUMMARY_NOTIFICATION_ID, summary)
} catch (_: SecurityException) {
// Permission can be revoked between the preflight check and notify().
}
}
private fun updateSummaryNotification() {
if (pendingMessages.size > 1) {
if (canPostNotifications()) postSummaryNotification()
} else {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
}
}
private fun createMessageChannel() {
val channel = NotificationChannel(
MESSAGE_CHANNEL_ID,
appContext.getString(R.string.message_channel_name),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = appContext.getString(R.string.message_channel_description)
enableVibration(true)
setShowBadge(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
}
appContext.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
private fun canPostNotifications(): Boolean {
val permissionGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
return permissionGranted && notificationManager.areNotificationsEnabled()
}
private fun conversationNotificationId(peerID: String): Int {
return CONVERSATION_NOTIFICATION_ID_BASE + (peerID.hashCode() and 0x3FFFFFFF)
}
}

View File

@ -0,0 +1,18 @@
package com.bitchat.watch.notification
/**
* Pure notification decisions shared by the watch service and unit tests.
*/
object WearNotificationPolicy {
fun shouldNotifyPrivateMessage(
senderPeerID: String,
senderIsSystem: Boolean,
appInForeground: Boolean,
openDmPeer: String?
): Boolean {
if (senderIsSystem) return false
return !appInForeground || openDmPeer != senderPeerID
}
fun activePeerCount(peers: Collection<String>): Int = peers.distinct().size
}

View File

@ -0,0 +1,168 @@
package com.bitchat.watch.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.MainActivity
import com.bitchat.watch.R
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.notification.WearNotificationCoordinator
import com.bitchat.watch.notification.WearNotificationPolicy
import com.bitchat.watch.ui.WearChatState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/**
* Keeps the BLE mesh (scan + advertise + GATT) alive while the app is backgrounded or the watch
* goes ambient. Bluetooth mesh only; no internet connectivity is used or declared.
*/
class WearMeshForegroundService : Service() {
companion object {
const val CHANNEL_ID = "bitchat_mesh"
const val NOTIFICATION_ID = 1
}
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var notificationCoordinator: WearNotificationCoordinator
private lateinit var mesh: WearMeshService
private var peerCountJob: Job? = null
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
notificationCoordinator = WearNotificationCoordinator.getInstance(applicationContext)
mesh = WearMeshService.getOrCreate(applicationContext)
createChannel()
startForeground(WearNotificationPolicy.activePeerCount(AppStateStore.peers.value))
observePeerCount()
mesh.onPrivateMessage = ::handlePrivateMessage
mesh.startServices()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
updateForegroundNotification(
WearNotificationPolicy.activePeerCount(AppStateStore.peers.value)
)
return START_STICKY
}
override fun onDestroy() {
peerCountJob?.cancel()
peerCountJob = null
if (::mesh.isInitialized) {
mesh.onPrivateMessage = null
mesh.stopServices()
}
serviceScope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.mesh_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.mesh_channel_description)
setShowBadge(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun startForeground(activePeers: Int) {
val notification = buildNotification(activePeers)
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
)
}
private fun observePeerCount() {
peerCountJob = serviceScope.launch {
AppStateStore.peers
.map(WearNotificationPolicy::activePeerCount)
.distinctUntilChanged()
.collect(::updateForegroundNotification)
}
}
private fun updateForegroundNotification(activePeers: Int) {
if (!canPostNotifications()) return
try {
notificationManager.notify(NOTIFICATION_ID, buildNotification(activePeers))
} catch (_: SecurityException) {
// Permission can be revoked between the preflight check and notify().
}
}
private fun handlePrivateMessage(message: BitchatMessage) {
val senderPeerID = message.senderPeerID ?: return
if (message.sender == "system") return
WearChatState.onPrivateMessageArrived(senderPeerID)
val senderNickname = mesh.getPeerNickname(senderPeerID)
?: message.sender.takeIf { it.isNotBlank() && it != senderPeerID }
?: senderPeerID.take(8)
notificationCoordinator.onPrivateMessage(
message = message,
senderPeerID = senderPeerID,
senderNickname = senderNickname
)
}
private fun buildNotification(activePeers: Int): Notification {
val launchIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(getString(R.string.app_name))
.setContentText(
resources.getQuantityString(
R.plurals.mesh_notification_text,
activePeers,
activePeers
)
)
.setContentIntent(launchIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
}
private fun canPostNotifications(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
}

View File

@ -0,0 +1,34 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
/**
* Scroll-aware bottom-bar visibility (typical dynamic-hide pattern): the bar hides while the
* user scrolls into history and reappears when they scroll back toward the newest messages.
* Always visible at the bottom (newest).
*
* Lists are normal (top-down) scrollables: value 0 = oldest, maxValue = newest (visual bottom).
*/
@Composable
fun rememberBottomBarVisibility(scrollState: ScrollState): State<Boolean> {
val visible = remember { mutableStateOf(true) }
LaunchedEffect(scrollState) {
var last = 0
snapshotFlow { scrollState.value to scrollState.maxValue }.collect { (value, max) ->
val atNewest = max - value < 40
when {
atNewest -> visible.value = true
value < last - 24 -> visible.value = false // scrolling up into history
value > last + 24 -> visible.value = true // back down toward newest
}
last = value
}
}
return visible
}

View File

@ -0,0 +1,247 @@
package com.bitchat.watch.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Mic
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.bitchat.watch.ui.media.WaveformBars
import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
/**
* Native Wear bottom action bar (designed for the ScreenScaffold `edgeButton` slot): a keyboard
* button that opens the text input screen, and a push-to-talk mic button press and hold to
* record, release to send. Recording state lives in [VoiceNoteController], hoisted at screen
* level so [VoiceRecordOverlay] can render full-screen outside this slot.
*/
@Composable
fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Modifier = Modifier) {
val context = LocalContext.current
val palette = LocalBitchatPalette.current
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) voice.start() }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(palette.inputButton)
.clickable { onKeyboard() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Keyboard,
contentDescription = "type message",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
Box(
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(
if (voice.recording) MaterialTheme.colorScheme.primary else palette.inputButton
)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val granted = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
if (granted) {
voice.start()
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
// Only a clean release stops here. If the scroll parent steals
// the pointer mid-drag (cancel), keep recording — the
// screen-level release watcher in ChatScaffold stops when the
// finger actually lifts, anywhere on the screen.
if (tryAwaitRelease()) {
voice.stop(send = true)
}
}
)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = "push to talk",
tint = if (voice.recording) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
}
}
/**
* Full-screen push-to-talk overlay: fades in over the chat with a live waveform, elapsed time,
* and a release hint. Rendered as a sibling of the screen content (NOT inside the edgeButton
* slot, which would clip it to the slot bounds).
*
* The big mic button doubles as the slide-to-cancel target: when the user's finger approaches
* it ([hoveringCancel]), it snaps into a red cancel button with a bouncy spring; lifting the
* finger there cancels the recording, dragging back out returns to send mode.
*/
@Composable
fun VoiceRecordOverlay(
voice: VoiceNoteController,
hoveringCancel: Boolean,
proximity: Float,
magnetPull: Offset,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
) {
val palette = LocalBitchatPalette.current
// The cancel morph, choreographed for feel:
// - color flows green→red CONTINUOUSLY as the finger approaches (finger-driven, so it
// is perfectly fluid), completing to full red on activation
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
// smooth spring so it lags and settles naturally
// - scale blooms with a soft bounce on activation — no rotation, no wobble
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "cancelSnap"
)
val pull by androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "magnetPull"
)
val cancelColor = androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f
)
AnimatedVisibility(
visible = voice.recording,
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero),
coords.size.toSize()
)
)
}
.size(52.dp)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon"
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(26.dp)
)
}
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
)
Text(
text = "%d:%02d".format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60
) + " / 0:10",
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 10.dp)
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color = if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}

View File

@ -0,0 +1,300 @@
package com.bitchat.watch.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.lazy.rememberTransformationSpec
import androidx.wear.compose.material3.lazy.transformedHeight
import com.bitchat.android.model.BitchatMessage
import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
/**
* The shared chat body for global chat and DM threads, following the classic messenger
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* scrollbar) with the header and action bar as floating overlays that get out of the way
* while scrolling up into history and return on any downward scroll; at the newest message
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
* hiding them never changes the scroll geometry. Earlier revisions animated the bottom
* clearance and resized the header in the layout path, which shifted content under the
* user's finger mid-gesture (felt as "resistance") and fed back into the dock detection.
*/
@Composable
fun ChatScaffold(
messages: List<BitchatMessage>,
myPeerID: String,
emptyText: String,
voice: VoiceNoteController,
onOpenImage: (String) -> Unit,
header: @Composable (expanded: Boolean) -> Unit,
actionBar: @Composable () -> Unit
) {
val columnState = rememberTransformingLazyColumnState()
// Haptics on incoming messages.
val context = LocalContext.current
var previousCount by remember { mutableStateOf(messages.size) }
LaunchedEffect(messages.size) {
if (messages.size > previousCount) {
val last = messages.lastOrNull()
if (last != null && last.senderPeerID != myPeerID) {
WearHaptics.knock(context)
}
}
previousCount = messages.size
}
// One state drives both overlays: visible at the bottom or when scrolling toward it,
// hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately
// small so the controls answer every flick immediately.
var atNewest by remember { mutableStateOf(true) }
val controlsVisible = remember { mutableStateOf(true) }
LaunchedEffect(columnState) {
var lastPosition = -1
snapshotFlow {
val first = columnState.layoutInfo.visibleItems.firstOrNull()
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
}.collect { (canScrollForward, index, offset) ->
val position = index * 100_000 + offset
atNewest = !canScrollForward
if (!canScrollForward) {
controlsVisible.value = true
} else if (lastPosition >= 0) {
when {
position > lastPosition + 24 -> controlsVisible.value = true
position < lastPosition - 24 -> controlsVisible.value = false
}
}
lastPosition = position
}
}
// Stick to bottom: follow new messages while resting at the newest.
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && atNewest) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
ChatBody(
messages = messages,
myPeerID = myPeerID,
emptyText = emptyText,
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
controlsVisible = controlsVisible.value,
header = header,
actionBar = actionBar,
modifier = Modifier.fillMaxSize()
)
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
myPeerID: String,
emptyText: String,
voice: VoiceNoteController,
onOpenImage: (String) -> Unit,
columnState: TransformingLazyColumnState,
controlsVisible: Boolean,
header: @Composable (expanded: Boolean) -> Unit,
actionBar: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val transformationSpec = rememberTransformationSpec()
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
// overlay's mic button reports its bounds and becomes the cancel target when the
// finger hovers it (with generous slack so the snap engages on approach).
var cancelBounds by remember { mutableStateOf<Rect?>(null) }
var fingerPos by remember { mutableStateOf(Offset.Zero) }
var fingerActive by remember { mutableStateOf(false) }
val hoveringCancel = fingerActive &&
cancelBounds?.inflate(CANCEL_HOVER_SLANT_PX)?.contains(fingerPos) == true
// Magnetic attraction: as the finger approaches the target (but is not on it yet), the
// button leans toward the finger and blushes red in proportion to the closeness;
// only actually entering the activation zone snaps it into full cancel mode.
val cancelCenter = cancelBounds?.center
val proximity: Float
val magnetPull: Offset
if (fingerActive && cancelCenter != null) {
val toFinger = fingerPos - cancelCenter
val dist = toFinger.getDistance()
proximity = ((MAGNET_OUTER_PX - dist) / (MAGNET_OUTER_PX - MAGNET_INNER_PX))
.coerceIn(0f, 1f)
magnetPull = if (dist > 1f) toFinger * (proximity * MAGNET_PULL_PX / dist)
else Offset.Zero
} else {
proximity = 0f
magnetPull = Offset.Zero
}
// Tactile tick each time the finger enters or leaves the cancel target.
var hoverHapticState by remember { mutableStateOf(false) }
LaunchedEffect(hoveringCancel, voice.recording) {
if (!voice.recording) {
hoverHapticState = false
} else if (hoveringCancel != hoverHapticState) {
WearHaptics.tick(context)
hoverHapticState = hoveringCancel
}
}
Box(
modifier = modifier
.fillMaxSize()
// Push-to-talk release is tracked globally: once recording, lifting the finger
// ANYWHERE on the screen stops — sending, or cancelling when hovering the
// cancel target. On a 1.4" round screen it is too easy to drift off the small
// mic button (the scrollable parent steals the pointer mid-drag), so the
// button alone must not own the release.
.pointerInput(voice) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (!voice.recording) continue
val change = event.changes.firstOrNull() ?: continue
fingerPos = change.position
fingerActive = true
if (event.changes.any { it.changedToUp() }) {
val cancel = cancelBounds
?.inflate(CANCEL_HOVER_SLANT_PX)
?.contains(change.position) == true
fingerActive = false
if (cancel) {
WearHaptics.reject(context)
voice.stop(send = false)
} else {
voice.stop(send = true)
}
}
}
}
}
) {
ScreenScaffold(scrollState = columnState) {
TransformingLazyColumn(
state = columnState,
modifier = Modifier.fillMaxSize(),
// Arrangement.Bottom anchors short content to the bottom: the first message
// starts just above the action bar and new messages push history upward.
// The padding reserves permanent room for the floating header and action
// bar; being constant, it never disturbs an in-flight scroll gesture.
verticalArrangement = Arrangement.Bottom,
contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp)
) {
if (messages.isEmpty()) {
item {
Text(
text = emptyText,
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 48.dp)
)
}
}
items(messages, key = { it.id }) { message ->
MessageItem(
message = message,
myPeerID = myPeerID,
onOpenImage = onOpenImage,
modifier = Modifier
.transformedHeight(this, transformationSpec)
.graphicsLayer {
with(transformationSpec) {
applyContainerTransformation(scrollProgress)
}
}
)
}
}
}
// The header stays put and shrinks to its dense form instead of disappearing;
// as an overlay its size animation never touches the list's scroll geometry.
Box(modifier = Modifier.align(Alignment.TopCenter)) {
header(controlsVisible)
}
AnimatedVisibility(
visible = controlsVisible,
modifier = Modifier.align(Alignment.BottomCenter),
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = tween(BitchatMotion.STANDARD_MS)
) + fadeIn(animationSpec = tween(BitchatMotion.STANDARD_MS)),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(BitchatMotion.STANDARD_MS)
) + fadeOut(animationSpec = tween(BitchatMotion.STANDARD_MS))
) {
Box(modifier = Modifier.padding(bottom = 10.dp)) {
actionBar()
}
}
VoiceRecordOverlay(
voice = voice,
hoveringCancel = hoveringCancel,
proximity = proximity,
magnetPull = magnetPull,
onCancelBounds = { cancelBounds = it }
)
}
}
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
// Magnetic zone geometry (px at watch density): the button starts reacting at
// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation
// boundary); it leans toward the finger by up to MAGNET_PULL_PX.
private const val MAGNET_OUTER_PX = 170f
private const val MAGNET_INNER_PX = 104f
private const val MAGNET_PULL_PX = 22f

View File

@ -0,0 +1,252 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MailOutline
import androidx.compose.material.icons.filled.People
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.ui.media.FileMessageChip
import com.bitchat.watch.ui.media.FullScreenImageViewer
import com.bitchat.watch.ui.media.ImageMessageItem
import com.bitchat.watch.ui.media.VoiceNoteItem
import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
val messages by AppStateStore.publicMessages.collectAsState()
val peers by AppStateStore.peers.collectAsState()
val unreadDms by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path ->
mesh?.let { sendVoiceNote(it, null, path) }
}
ChatScaffold(
messages = messages,
myPeerID = myPeerID,
emptyText = "No messages yet\nSay hi to the mesh",
voice = voice,
onOpenImage = { viewerPath = it },
header = { expanded ->
ChatHeader(
peerCount = peers.size,
unreadDms = unreadDms.values.sum(),
expanded = expanded,
onOpenPeople = onOpenPeople
)
},
actionBar = {
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice)
}
)
viewerPath?.let { path ->
FullScreenImageViewer(path = path, onClose = { viewerPath = null })
}
}
@Composable
private fun ChatHeader(
peerCount: Int,
unreadDms: Int,
expanded: Boolean,
onOpenPeople: () -> Unit
) {
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon"
)
val titleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
)
val vPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
)
// The entire header region opens the People screen. When there are unread DMs the
// title gives way so the people and mail icons (with counts) fit side by side on the
// round screen instead of clipping at the edges.
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenPeople() }
.padding(horizontal = 8.dp, vertical = vPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (unreadDms == 0) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
}
Icon(
imageVector = Icons.Filled.People,
contentDescription = "people",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(iconSize)
)
Text(
text = "$peerCount",
style = MaterialTheme.typography.bodySmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 2.dp)
)
if (unreadDms > 0) {
Icon(
imageVector = Icons.Filled.MailOutline,
contentDescription = "$unreadDms unread messages",
tint = LocalBitchatPalette.current.accentOrange,
modifier = Modifier
.padding(start = 6.dp)
.size(iconSize)
)
Text(
text = "$unreadDms",
style = MaterialTheme.typography.bodySmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
color = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(start = 2.dp)
)
}
}
}
@Composable
fun MessageItem(
message: BitchatMessage,
myPeerID: String,
onOpenImage: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val isSelf = message.senderPeerID == myPeerID
val senderColor = when {
isSelf -> palette.accentOrange
else -> colorForPeer(message.sender + (message.senderPeerID ?: ""), palette)
}
// Snappy appear animation for incoming messages (BitchatMotion.EMPHASIZED_MS)
var appeared by remember { mutableStateOf(false) }
LaunchedEffect(message.id) { appeared = true }
val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (appeared) 1f else 0f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS),
label = "msgAlpha"
)
val offset by androidx.compose.animation.core.animateDpAsState(
targetValue = if (appeared) 0.dp else 6.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS),
label = "msgOffset"
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 3.dp)
.offset(y = offset)
.alpha(alpha)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isSelf) "you" else message.sender,
style = ChatVisualTokens.SenderStyle,
color = senderColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
Text(
text = " ${formatTime(message.timestamp)}",
style = ChatVisualTokens.SystemActionStyle,
fontSize = 9.sp,
color = palette.textTertiary
)
}
when (message.type) {
BitchatMessageType.Image -> ImageMessageItem(
path = message.content.trim(),
onOpen = onOpenImage
)
BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim())
BitchatMessageType.File -> {
val path = message.content.trim()
val file = remember(path) { File(path) }
val sizeBytes = remember(path) { file.length() }
FileMessageChip(name = file.name, sizeBytes = sizeBytes)
}
BitchatMessageType.Message -> Text(
text = message.content,
style = ChatVisualTokens.MessageBodyStyle,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 1.dp)
)
}
}
}
private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
private fun formatTime(date: Date): String = timeFormat.format(date)

View File

@ -0,0 +1,150 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.notification.WearNotificationCoordinator
import com.bitchat.watch.ui.media.FullScreenImageViewer
import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer
@Composable
fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
val context = LocalContext.current
val privateMessages by AppStateStore.privateMessages.collectAsState()
val messages = privateMessages[peerID] ?: emptyList()
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
val palette = LocalBitchatPalette.current
var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path ->
mesh?.let { sendVoiceNote(it, peerID, path) }
}
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
var sessionEstablished by remember {
mutableStateOf(mesh?.hasEstablishedSession(peerID) == true)
}
DisposableEffect(peerID) {
WearChatState.openDm(peerID)
WearNotificationCoordinator.getInstance(context).clearConversation(peerID)
onDispose { WearChatState.closeDm() }
}
LaunchedEffect(peerID) {
if (mesh?.hasEstablishedSession(peerID) != true) {
try { mesh?.initiateNoiseHandshake(peerID) } catch (_: Exception) { }
}
while (true) {
sessionEstablished = mesh?.hasEstablishedSession(peerID) == true
kotlinx.coroutines.delay(2_000)
}
}
ChatScaffold(
messages = messages,
myPeerID = myPeerID,
emptyText = if (sessionEstablished) "Encrypted channel ready\nSay hi"
else "Setting up encryption…",
voice = voice,
onOpenImage = { viewerPath = it },
header = { expanded ->
DmHeader(
nickname = nickname,
peerID = peerID,
sessionEstablished = sessionEstablished,
expanded = expanded
)
},
actionBar = {
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice)
}
)
viewerPath?.let { path ->
FullScreenImageViewer(path = path, onClose = { viewerPath = null })
}
}
@Composable
private fun DmHeader(
nickname: String,
peerID: String,
sessionEstablished: Boolean,
expanded: Boolean
) {
val palette = LocalBitchatPalette.current
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon"
)
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle"
)
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad"
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = headerVPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = nickname,
style = MaterialTheme.typography.titleSmall,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
headerTitleSize.toSp()
},
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette)
)
NoiseLockIcon(
state = if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
size = headerIconSize,
modifier = Modifier.padding(start = 5.dp)
)
}
}

View File

@ -0,0 +1,134 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
/**
* Nickname entry, used both for first-run onboarding and for renaming later. The IME's
* Done action only closes the keyboard so the user can review the name; the confirm
* button is the single commit path.
*/
@Composable
fun NicknameSetupScreen(
initialNickname: String,
title: String = "bitchat",
subtitle: String = "Pick a nickname",
confirmLabel: String = "Join the mesh",
onConfirm: (String) -> Unit
) {
val palette = LocalBitchatPalette.current
// Pre-fill with the cursor at the end of the existing name, not the start.
var name by remember {
mutableStateOf(
TextFieldValue(
text = initialNickname,
selection = TextRange(initialNickname.length)
)
)
}
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
)
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name = if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary
)
}
innerTextField()
}
}
)
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp)
) {
Text(confirmLabel)
}
}
}

View File

@ -0,0 +1,76 @@
package com.bitchat.watch.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
import com.bitchat.watch.ui.theme.LocalBitchatPalette
enum class NoiseSessionUiState { Idle, Handshaking, Established }
/**
* Noise session lock icon, same visual language as the phone's NoiseSessionIcon: quiet grey
* open lock when idle, orange open lock with a soft pulse while the handshake is in flight,
* green closed lock once established. Tint and glyph transitions land together.
*/
@Composable
fun NoiseLockIcon(
state: NoiseSessionUiState,
modifier: Modifier = Modifier,
size: androidx.compose.ui.unit.Dp = 13.dp
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val targetTint = when (state) {
NoiseSessionUiState.Handshaking -> palette.accentOrange
NoiseSessionUiState.Established -> colorScheme.primary
NoiseSessionUiState.Idle -> colorScheme.onSurfaceVariant
}
val tint by animateColorAsState(
targetValue = targetTint,
animationSpec = tween(480, easing = FastOutSlowInEasing),
label = "noiseLockTint"
)
val pulseAlpha = if (state == NoiseSessionUiState.Handshaking) {
val transition = rememberInfiniteTransition(label = "noiseLockPulse")
transition.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "noiseLockPulseAlpha"
).value
} else 1f
Icon(
imageVector = if (state == NoiseSessionUiState.Established) Icons.Filled.Lock
else Icons.Filled.LockOpen,
contentDescription = when (state) {
NoiseSessionUiState.Handshaking -> "handshake in progress"
NoiseSessionUiState.Established -> "encrypted"
NoiseSessionUiState.Idle -> "not encrypted yet"
},
tint = tint,
modifier = modifier
.size(size)
.alpha(pulseAlpha)
)
}

View File

@ -0,0 +1,98 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer
/**
* Internal debug screen (M2): raw peer list with RSSI. Kept for troubleshooting; the real
* people screen arrives in M4.
*/
@Composable
fun PeerDebugScreen() {
val peers by AppStateStore.peers.collectAsState()
val mesh = WearMeshService.peek()
val listState = rememberScalingLazyListState()
val palette = LocalBitchatPalette.current
val nicknames = mesh?.getPeerNicknames() ?: emptyMap()
val rssi = mesh?.getPeerRSSI() ?: emptyMap()
ScreenScaffold(scrollState = listState) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
item {
ListHeader {
Text(
text = "Peers (${peers.size})",
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
}
if (peers.isEmpty()) {
item {
Text(
text = "Scanning for bitchat devices…",
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
)
}
}
items(peers) { peerID ->
val nick = nicknames[peerID] ?: peerID.take(8)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = nick,
style = MaterialTheme.typography.bodyMedium,
color = colorForPeer(nick + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
rssi[peerID]?.let {
Text(
text = "${it}dBm",
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
modifier = Modifier.padding(start = 6.dp)
)
}
}
}
}
}
}

View File

@ -0,0 +1,201 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MailOutline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.Card
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.ListHeader
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import com.bitchat.watch.ui.theme.colorForPeer
@Composable
fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
val peers by AppStateStore.peers.collectAsState()
val unread by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
val listState = rememberScalingLazyListState()
val palette = LocalBitchatPalette.current
val nicknames = mesh?.getPeerNicknames() ?: emptyMap()
// Peers with unread messages float to the top so they are easy to see and reach.
val sortedPeers = androidx.compose.runtime.remember(peers, unread, nicknames) {
peers.sortedWith(
compareByDescending<String> { (unread[it] ?: 0) > 0 }
.thenBy { (nicknames[it] ?: it).lowercase() }
)
}
ScreenScaffold(scrollState = listState) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
item {
ListHeader {
Text(
text = "People (${peers.size})",
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
}
if (peers.isEmpty()) {
item {
Text(
text = "No one nearby yet\nKeep the app open to mesh",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
)
}
}
item(key = "self") {
SelfRow(
nickname = mesh?.nickname ?: "me",
onClick = onEditNickname
)
}
items(sortedPeers, key = { it }) { peerID ->
val nick = nicknames[peerID] ?: peerID.take(8)
PersonRow(
nickname = nick,
peerID = peerID,
encrypted = mesh?.hasEstablishedSession(peerID) == true,
unreadCount = unread[peerID] ?: 0,
onClick = { onOpenDm(peerID) }
)
}
}
}
}
@Composable
private fun SelfRow(nickname: String, onClick: () -> Unit) {
val palette = LocalBitchatPalette.current
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 2.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = nickname,
style = ChatVisualTokens.SenderStyle,
color = palette.accentOrange,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
Text(
text = " (you)",
style = ChatVisualTokens.SenderStyle,
color = palette.textTertiary
)
}
Text(
text = "Tap to rename",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)
}
}
}
@Composable
private fun PersonRow(
nickname: String,
peerID: String,
encrypted: Boolean,
unreadCount: Int,
onClick: () -> Unit
) {
val palette = LocalBitchatPalette.current
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = nickname,
style = ChatVisualTokens.SenderStyle,
color = colorForPeer(nickname + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (encrypted) {
NoiseLockIcon(
state = NoiseSessionUiState.Established,
size = 11.dp,
modifier = Modifier.padding(start = 4.dp)
)
}
}
if (!encrypted) {
Text(
text = "Tap to chat",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)
}
}
if (unreadCount > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp)
) {
Icon(
imageVector = Icons.Filled.MailOutline,
contentDescription = "$unreadCount unread messages",
tint = palette.accentOrange,
modifier = Modifier.size(13.dp)
)
Text(
text = "$unreadCount",
style = ChatVisualTokens.SystemActionStyle,
color = palette.accentOrange,
modifier = Modifier.padding(start = 2.dp)
)
}
}
}
}
}

View File

@ -0,0 +1,107 @@
package com.bitchat.watch.ui
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.AppStateStore
import com.bitchat.watch.mesh.WearMeshService
import java.io.File
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal fun sendPublicMessage(mesh: WearMeshService, content: String) {
mesh.sendMessage(content)
AppStateStore.addPublicMessage(
BitchatMessage(
sender = mesh.nickname,
content = content,
timestamp = Date(),
senderPeerID = mesh.myPeerID,
deliveryStatus = DeliveryStatus.Sent
)
)
}
/**
* DM send with honest delivery state. MeshCore drops pre-handshake content (it only kicks
* off the Noise handshake), so when no session exists we must not echo "Sent": the echo
* stays "Sending" while a retry loop waits for the session and completes the send.
*/
internal fun sendPrivateMessage(
mesh: WearMeshService,
peerID: String,
recipientNickname: String,
content: String,
scope: CoroutineScope
) {
val established = mesh.hasEstablishedSession(peerID)
val messageID = java.util.UUID.randomUUID().toString()
if (established) {
mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID)
} else {
mesh.initiateNoiseHandshake(peerID)
scope.launch {
val deadline = System.currentTimeMillis() + 15_000
while (System.currentTimeMillis() < deadline) {
if (mesh.hasEstablishedSession(peerID)) {
mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID)
AppStateStore.updatePrivateMessageStatus(messageID, DeliveryStatus.Sent)
return@launch
}
delay(400)
}
// Session never came up: the echo honestly stays "Sending" (AppStateStore
// refuses status downgrades, so it cannot be marked Failed from here).
}
}
AppStateStore.addPrivateMessage(
peerID,
BitchatMessage(
id = messageID,
sender = mesh.nickname,
content = content,
timestamp = Date(),
isPrivate = true,
recipientNickname = recipientNickname,
senderPeerID = mesh.myPeerID,
deliveryStatus = if (established) DeliveryStatus.Sent else DeliveryStatus.Sending
)
)
}
/**
* Send a recorded voice note. Global chat: broadcast file packet. DM thread: Noise-encrypted
* private file transfer. Local echo renders immediately (content = local path, type = Audio).
*/
internal fun sendVoiceNote(mesh: WearMeshService, peerID: String?, path: String) {
val file = File(path)
if (!file.isFile) return
val packet = BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "audio/mp4",
content = file.readBytes()
)
if (peerID == null) {
mesh.sendFileBroadcast(packet)
} else {
mesh.sendFilePrivateEncrypted(peerID, packet)
}
val echo = BitchatMessage(
sender = mesh.nickname,
content = path,
type = BitchatMessageType.Audio,
timestamp = Date(),
isPrivate = peerID != null,
senderPeerID = mesh.myPeerID,
deliveryStatus = DeliveryStatus.Sent
)
if (peerID == null) {
AppStateStore.addPublicMessage(echo)
} else {
AppStateStore.addPrivateMessage(peerID, echo)
}
}

View File

@ -0,0 +1,159 @@
package com.bitchat.watch.ui
import android.app.Activity
import android.content.Intent
import android.speech.RecognizerIntent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Mic
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.IconButton
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
/**
* Full-screen text input: field auto-focused so the watch IME (with its built-in dictation)
* opens immediately, plus a dedicated dictation button using the system speech recognizer.
*/
@Composable
fun TextInputScreen(onSend: (String) -> Unit) {
val palette = LocalBitchatPalette.current
val context = androidx.compose.ui.platform.LocalContext.current
var text by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
val dictationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val spoken = result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
if (!spoken.isNullOrBlank()) {
WearHaptics.tick(context)
onSend(spoken.trim())
}
}
}
fun send() {
val trimmed = text.trim()
if (trimmed.isNotEmpty()) {
keyboardController?.hide()
WearHaptics.tick(context)
onSend(trimmed)
text = ""
}
}
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center
) {
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { send() }),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 14.dp, vertical = 10.dp),
decorationBox = { innerTextField ->
Box {
if (text.isEmpty()) {
Text(
text = "Message",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary
)
}
innerTextField()
}
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
dictationLauncher.launch(
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak your message")
}
)
},
modifier = Modifier.size(38.dp)
) {
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = "dictate",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(
onClick = { send() },
enabled = text.isNotBlank(),
modifier = Modifier.size(38.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "send",
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary
else palette.textTertiary
)
}
}
}
}

View File

@ -0,0 +1,92 @@
package com.bitchat.watch.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.features.voice.VoiceRecorder
import com.bitchat.android.features.voice.normalizeAmplitudeSample
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val MAX_RECORDING_MS = 10_000L
private const val MIN_RECORDING_MS = 600L
private const val AMPLITUDE_POLL_MS = 80L
private const val LIVE_BARS = 32
/**
* Push-to-talk recording controller: start on press, stop on release, 10 s cap, ~80 ms
* amplitude polls into a rolling live-waveform buffer. Hoisted to screen level so the
* full-screen overlay can render outside the edge-button slot.
*/
class VoiceNoteController(
private val context: Context,
private val scope: CoroutineScope,
private val onSendVoice: (String) -> Unit
) {
private val recorder = VoiceRecorder(context.applicationContext)
var recording by mutableStateOf(false)
private set
var elapsedMs by mutableLongStateOf(0L)
private set
var liveSamples by mutableStateOf(FloatArray(LIVE_BARS))
private set
private var pollJob: Job? = null
private var startedAt = 0L
fun start() {
if (recording) return
recorder.start() ?: return
startedAt = System.currentTimeMillis()
elapsedMs = 0L
liveSamples = FloatArray(LIVE_BARS)
recording = true
WearHaptics.knock(context)
pollJob = scope.launch {
while (true) {
delay(AMPLITUDE_POLL_MS)
val amp = normalizeAmplitudeSample(recorder.pollAmplitude())
liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp
val elapsed = System.currentTimeMillis() - startedAt
elapsedMs = elapsed
if (elapsed >= MAX_RECORDING_MS) {
stop(send = true)
break
}
}
}
}
fun stop(send: Boolean) {
if (!recording) return
recording = false
// The send path clicks; the cancel path stays silent here because the caller
// plays its own reject haptic.
if (send) WearHaptics.click(context)
pollJob?.cancel()
pollJob = null
val file = recorder.stop()
val elapsed = System.currentTimeMillis() - startedAt
if (send && file != null && elapsed >= MIN_RECORDING_MS) {
onSendVoice(file.absolutePath)
} else {
file?.delete()
}
}
}
@Composable
fun rememberVoiceNoteController(onSendVoice: (String) -> Unit): VoiceNoteController {
val context = LocalContext.current
val scope = rememberCoroutineScope()
return remember { VoiceNoteController(context, scope, onSendVoice) }
}

View File

@ -0,0 +1,43 @@
package com.bitchat.watch.ui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-wide UI state for the watch app: unread DM counters and the currently open DM thread.
*/
object WearChatState {
private val _unreadDms = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadDms: StateFlow<Map<String, Int>> = _unreadDms.asStateFlow()
@Volatile
var appInForeground: Boolean = false
private set
@Volatile
var openDmPeer: String? = null
@Synchronized
fun onPrivateMessageArrived(peerID: String) {
if (appInForeground && openDmPeer == peerID) return
_unreadDms.value = _unreadDms.value + (peerID to ((_unreadDms.value[peerID] ?: 0) + 1))
}
fun setAppInForeground(inForeground: Boolean) {
appInForeground = inForeground
}
@Synchronized
fun openDm(peerID: String) {
openDmPeer = peerID
_unreadDms.value = _unreadDms.value - peerID
}
@Synchronized
fun closeDm() {
openDmPeer = null
}
fun unreadCount(peerID: String): Int = _unreadDms.value[peerID] ?: 0
}

View File

@ -0,0 +1,35 @@
package com.bitchat.watch.ui
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
/**
* Tactile accents for the watch's important moments. Uses predefined vibration effects so
* the feel stays consistent with the rest of Wear OS.
*/
object WearHaptics {
/** Firm knock: recording started, message received. */
fun knock(context: Context) = vibrate(context, VibrationEffect.EFFECT_HEAVY_CLICK)
/** Crisp click: recording stopped, message sent. */
fun click(context: Context) = vibrate(context, VibrationEffect.EFFECT_CLICK)
/** Double tap: destructive/cancel confirmation. */
fun reject(context: Context) = vibrate(context, VibrationEffect.EFFECT_DOUBLE_CLICK)
/** Light tick: small confirmations. */
fun tick(context: Context) = vibrate(context, VibrationEffect.EFFECT_TICK)
private fun vibrate(context: Context, effect: Int) {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Vibrator::class.java)
} ?: return
vibrator.vibrate(VibrationEffect.createPredefined(effect))
}
}

View File

@ -0,0 +1,306 @@
package com.bitchat.watch.ui.media
import android.graphics.BitmapFactory
import android.media.MediaPlayer
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.delay
import java.io.File
/**
* Compact inline image thumbnail; tap opens the full-screen viewer.
*/
@Composable
fun ImageMessageItem(path: String, onOpen: (String) -> Unit) {
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
if (bitmap == null) {
FileMessageChip(name = File(path).name, sizeBytes = File(path).length())
return
}
Image(
painter = BitmapPainter(bitmap.asImageBitmap()),
contentDescription = "image",
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(top = 2.dp)
.widthIn(max = 120.dp)
.aspectRatio(
(bitmap.width.toFloat() / bitmap.height.toFloat()).coerceIn(0.6f, 1.8f)
)
.clip(RoundedCornerShape(10.dp))
.clickable { onOpen(path) }
)
}
/**
* Full-screen image viewer (mirrors the phone's FullScreenImageViewer): black surface,
* fit-to-screen, tap or swipe-back to dismiss.
*/
@Composable
fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable { onClose() },
contentAlignment = Alignment.Center
) {
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
if (bitmap != null) {
Image(
painter = BitmapPainter(bitmap.asImageBitmap()),
contentDescription = "image fullscreen",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
)
}
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "close",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 24.dp)
.size(20.dp)
)
}
}
}
/**
* Voice-note bubble: play/pause + waveform (120 bins, extracted locally like the phone) +
* duration/progress. Playback via MediaPlayer.
*/
@Composable
fun VoiceNoteItem(path: String) {
val palette = LocalBitchatPalette.current
var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) }
var playing by remember { mutableStateOf(false) }
var progress by remember { mutableFloatStateOf(0f) }
var durationMs by remember { mutableIntStateOf(0) }
val player = remember { MediaPlayer() }
LaunchedEffect(path) {
if (samples == null) {
AudioWaveformExtractor.extractAsync(path) { extracted ->
if (extracted != null) {
VoiceWaveformCache.put(path, extracted)
samples = extracted
}
}
}
}
DisposableEffect(path) {
runCatching {
player.reset()
player.setDataSource(path)
player.setOnCompletionListener {
playing = false
progress = 0f
}
player.prepare()
durationMs = player.duration
}
onDispose {
runCatching { if (player.isPlaying) player.stop() }
runCatching { player.release() }
}
}
LaunchedEffect(playing) {
while (playing) {
progress = if (durationMs > 0) player.currentPosition.toFloat() / durationMs else 0f
delay(100)
}
}
Row(
modifier = Modifier
.padding(top = 2.dp)
.clip(RoundedCornerShape(12.dp))
.background(palette.inputSurface)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.clickable {
if (playing) {
player.pause()
playing = false
} else {
runCatching { player.start() }
playing = true
}
},
contentAlignment = Alignment.Center
) {
if (playing) {
Canvas(modifier = Modifier.size(10.dp)) {
val w = size.width
val h = size.height
drawRoundRect(
color = Color.Black,
topLeft = Offset(0f, 0f),
size = Size(w * 0.35f, h),
cornerRadius = CornerRadius(1.dp.toPx())
)
drawRoundRect(
color = Color.Black,
topLeft = Offset(w * 0.65f, 0f),
size = Size(w * 0.35f, h),
cornerRadius = CornerRadius(1.dp.toPx())
)
}
} else {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "play",
tint = Color.Black,
modifier = Modifier.size(16.dp)
)
}
}
WaveformBars(
samples = samples,
progress = progress,
modifier = Modifier
.padding(start = 6.dp)
.weight(1f)
.height(22.dp),
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = palette.textTertiary.copy(alpha = 0.5f)
)
Text(
text = formatDuration(if (playing) (durationMs * progress).toInt() else durationMs),
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary,
modifier = Modifier.padding(start = 6.dp)
)
}
}
@Composable
fun WaveformBars(
samples: FloatArray?,
progress: Float,
modifier: Modifier = Modifier,
activeColor: Color,
inactiveColor: Color
) {
Canvas(modifier = modifier) {
val bars = 32
val values = samples?.let { com.bitchat.android.features.voice.resampleWave(it, bars) }
?: FloatArray(bars) { 0.3f }
val barWidth = size.width / (bars * 2 - 1)
for (i in 0 until bars) {
val v = values.getOrElse(i) { 0f }.coerceIn(0.08f, 1f)
val barHeight = size.height * v
val x = i * barWidth * 2
drawRoundRect(
color = if (i.toFloat() / bars <= progress) activeColor else inactiveColor,
topLeft = Offset(x, (size.height - barHeight) / 2f),
size = Size(barWidth, barHeight),
cornerRadius = CornerRadius(barWidth / 2f)
)
}
}
}
/**
* Compact chip for non-media files.
*/
@Composable
fun FileMessageChip(name: String, sizeBytes: Long) {
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.padding(top = 2.dp)
.clip(RoundedCornerShape(12.dp))
.background(palette.inputSurface)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = name,
style = ChatVisualTokens.SystemActionStyle,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = formatSize(sizeBytes),
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)
}
}
}
private fun formatDuration(ms: Int): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
}
private fun formatSize(bytes: Long): String = when {
bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576f)
bytes >= 1024 -> "%.1f KB".format(bytes / 1024f)
else -> "$bytes B"
}

View File

@ -0,0 +1,38 @@
package com.bitchat.watch.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class BitchatPalette(
val inputOutline: Color,
val inputOutlineFocused: Color,
val inputSurface: Color,
val inputSurfaceFocused: Color,
val inputButton: Color,
val textTertiary: Color,
val accentOrange: Color,
val accentPurple: Color,
val peerColors: PeerColorStyle,
)
val DarkBitchatPalette = BitchatPalette(
inputOutline = Color(0xFF333635),
inputOutlineFocused = Color(0xFF5A605D),
inputSurface = Color(0xFF0B0B0B),
inputSurfaceFocused = Color(0xFF151515),
inputButton = Color(0xFF1E1E1E),
textTertiary = Color(0xFF6B776B),
accentOrange = Color(0xFFFF9F0A),
accentPurple = Color(0xFFBF5AF2),
peerColors = PeerColorStyle.Dark,
)
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
object BitchatMotion {
const val QUICK_MS = 120
const val STANDARD_MS = 180
const val EMPHASIZED_MS = 240
}

View File

@ -0,0 +1,35 @@
package com.bitchat.watch.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
@Immutable
data class PeerColorStyle(
val saturation: Float,
val value: Float,
) {
companion object {
val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f)
}
}
fun colorForPeer(stableKey: String, palette: BitchatPalette): Color {
var hash = 5381UL
for (byte in stableKey.toByteArray()) {
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
}
var hue = (hash % 360UL).toDouble() / 360.0
val orange = 30.0 / 360.0
if (abs(hue - orange) < 0.05) {
hue = (hue + 0.12) % 1.0
}
val style = palette.peerColors
return Color.hsv(
hue = (hue * 360).toFloat(),
saturation = style.saturation,
value = style.value
)
}

View File

@ -0,0 +1,42 @@
package com.bitchat.watch.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.wear.compose.material3.ColorScheme
import androidx.wear.compose.material3.MaterialTheme
val BitchatWearColorScheme = ColorScheme(
primary = Color(0xFF32D74B),
onPrimary = Color.Black,
primaryContainer = Color(0xFF163D1D),
onPrimaryContainer = Color(0xFFB8F5C1),
secondary = Color(0xFF0A84FF),
onSecondary = Color.Black,
secondaryContainer = Color(0xFF082E54),
onSecondaryContainer = Color(0xFFC2E0FF),
tertiary = Color(0xFFFF9F0A),
onTertiary = Color.Black,
background = Color(0xFF000000),
onBackground = Color(0xFFF5F5F5),
surfaceContainer = Color(0xFF0E150E),
surfaceContainerLow = Color(0xFF0B0B0B),
surfaceContainerHigh = Color(0xFF182118),
onSurface = Color(0xFFF5F5F5),
onSurfaceVariant = Color(0xFF9AA69A),
outline = Color(0xFF2A3A2A),
outlineVariant = Color(0xFF1C271C),
error = Color(0xFFFF453A),
onError = Color.Black,
)
@Composable
fun BitchatWearTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalBitchatPalette provides DarkBitchatPalette) {
MaterialTheme(
colorScheme = BitchatWearColorScheme,
typography = BitchatWearTypography,
content = content
)
}
}

View File

@ -0,0 +1,43 @@
package com.bitchat.watch.ui.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.wear.compose.material3.Typography
import com.bitchat.watch.R
val BitchatFontFamily = FontFamily(
Font(R.font.geist_mono_regular, FontWeight.Normal),
Font(R.font.geist_mono_medium, FontWeight.Medium),
Font(R.font.geist_mono_semibold, FontWeight.SemiBold),
Font(R.font.geist_mono_bold, FontWeight.Bold),
)
val BitchatWearTypography = Typography(
defaultFontFamily = BitchatFontFamily,
)
object ChatVisualTokens {
val MessageBodyStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 17.sp,
)
val SenderStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
lineHeight = 15.sp,
)
val SystemActionStyle = TextStyle(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
)
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
<path
android:pathData="M-0.2617076 -0.1145289H135.7284V135.5812H-0.2617076V-0.1145289Z"
android:fillColor="#000000" />
</vector>

View File

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#FFFFFF" />
</group>
</vector>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:name="vector"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#000000" />
</group>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,3h16c1.1,0 2,0.9 2,2v11c0,1.1 -0.9,2 -2,2H7l-5,4V5c0,-1.1 0.9,-2 2,-2z" />
</vector>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="BitchatLaunchTheme" parent="android:Theme.DeviceDefault">
<item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenIconBackgroundColor">#000000</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">bitchat</string>
<string name="mesh_channel_name">Mesh network</string>
<string name="mesh_channel_description">Keeps the Bluetooth mesh running in the background</string>
<plurals name="mesh_notification_text">
<item quantity="one">Mesh running — %1$d peer</item>
<item quantity="other">Mesh running — %1$d peers</item>
</plurals>
<string name="message_channel_name">Direct messages</string>
<string name="message_channel_description">Encrypted direct-message alerts</string>
<string name="notification_new_private_message">New encrypted message</string>
<string name="notification_private_message_hidden">Unlock to view the message</string>
<plurals name="notification_private_message_summary">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
</resources>

View File

@ -0,0 +1,62 @@
package com.bitchat.watch.notification
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WearNotificationPolicyTest {
@Test
fun `system private messages never notify`() {
assertFalse(
WearNotificationPolicy.shouldNotifyPrivateMessage(
senderPeerID = "peer-a",
senderIsSystem = true,
appInForeground = false,
openDmPeer = null
)
)
}
@Test
fun `visible matching dm suppresses notification`() {
assertFalse(
WearNotificationPolicy.shouldNotifyPrivateMessage(
senderPeerID = "peer-a",
senderIsSystem = false,
appInForeground = true,
openDmPeer = "peer-a"
)
)
}
@Test
fun `backgrounded matching dm still notifies`() {
assertTrue(
WearNotificationPolicy.shouldNotifyPrivateMessage(
senderPeerID = "peer-a",
senderIsSystem = false,
appInForeground = false,
openDmPeer = "peer-a"
)
)
}
@Test
fun `different visible dm still notifies`() {
assertTrue(
WearNotificationPolicy.shouldNotifyPrivateMessage(
senderPeerID = "peer-a",
senderIsSystem = false,
appInForeground = true,
openDmPeer = "peer-b"
)
)
}
@Test
fun `peer count is distinct`() {
assertEquals(2, WearNotificationPolicy.activePeerCount(listOf("peer-a", "peer-a", "peer-b")))
}
}