diff --git a/AGENTS.md b/AGENTS.md index 5244db5f..edc167b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --serial-b --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk` + - Run: `python3 tools/release_gate/mesh_lab.py scenario all --serial-a --serial-b --out /tmp/meshlab-evidence` + - Scenarios: `dm`, `broadcast`, `file`, `file_oversize`, `file_private`, `raw`, `session_recovery`, `identity_reset`, `all`. Ad-hoc: `... cmd --serial state`. - **Execution**: - Unit: `./gradlew test` - Instrumented: `./gradlew connectedAndroidTest` diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..e84ea5db --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt new file mode 100644 index 00000000..a2e17b9b --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -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): JSONArray { + val nicknames = mesh.getPeerNicknames() + val rssi = mesh.getPeerRSSI() + val arr = JSONArray() + peerIds.forEach { id -> + val info = mesh.getPeerInfo(id) + arr.put(JSONObject() + .put("id", id) + .put("nickname", nicknames[id] ?: info?.nickname) + .put("rssi", rssi[id]) + .put("direct", AppStateStore.directPeers.value.contains(id)) + .put("connected", info?.isConnected) + .put("last_seen", info?.lastSeen) + .put("session", mesh.getSessionState(id).toString()) + .put("fingerprint", mesh.getPeerFingerprint(id))) + } + return arr + } + + private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd) + private fun err(cmd: String, message: String) = + JSONObject().put("status", "error").put("cmd", cmd).put("error", message) + + private fun Intent.requiredString(name: String): String = + getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name") + + private fun sha256Hex(data: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(data).toHex() + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun hexToBytes(hex: String): ByteArray? { + val clean = hex.replace(" ", "") + if (clean.length % 2 != 0) return null + return try { + ByteArray(clean.length / 2) { i -> + clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt new file mode 100644 index 00000000..3f549d5d --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.testhook + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import java.io.File + +/** + * ADB-drivable test hook (debug builds only). + * + * Usage: + * adb shell am broadcast -a com.bitchat.droid.TEST_HOOK \ + * --es cmd --es id [command extras...] + * + * Result is written to cache/testhook/results/.json and logged under tag TestHook: + * adb shell run-as com.bitchat.droid cat cache/testhook/results/.json + */ +class TestHookReceiver : BroadcastReceiver() { + + companion object { + const val TAG = "TestHook" + const val ACTION = "com.bitchat.droid.TEST_HOOK" + private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION) return + val cmd = intent.getStringExtra("cmd") ?: "ping" + val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}" + val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS) + + Log.i(TAG, "CMD id=$id cmd=$cmd") + + val pendingResult = goAsync() + Thread { + val result = try { + runBlocking { + withTimeout(overallTimeout) { + TestHookDriver.execute(context.applicationContext, cmd, intent) + } + } + } catch (e: Exception) { + JSONObject() + .put("status", "error") + .put("cmd", cmd) + .put("error", "${e.javaClass.simpleName}: ${e.message}") + } + try { + val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() } + File(dir, "$id.json").writeText(result.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to write result file for $id: ${e.message}") + } + Log.i(TAG, "RESULT id=$id $result") + }.start() + // Finish immediately: long-running commands continue on the worker thread and + // report via the result file. Holding the broadcast open past the system + // broadcast window would ANR the app. + pendingResult.finish() + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index dbd3888b..bf9745d9 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt index bf2f3a93..3a22b6b1 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -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.") } } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotPermissions.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotPermissions.kt new file mode 100644 index 00000000..122ea8c2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotPermissions.kt @@ -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 { + 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 { + return requiredForSdk().filter { permission -> + ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt index 3df51baf..9c6bd15e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt b/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt index fbffb9aa..8dab20a2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt @@ -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)) } } } - diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 67065182..4e42e141 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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) ) diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt index 0b4e0005..aea1afac 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -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() diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 68797e08..f9297afa 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -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()) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt b/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt new file mode 100644 index 00000000..6603795a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt index 87fe3fb7..82d51033 100644 --- a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt +++ b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt @@ -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 { if (!apk.isFile) return emptySet() return try { @@ -189,3 +207,8 @@ object DistributionInfoProvider { val certificateSha256: String? ) } + +enum class ShareableApkVariant { + UNIVERSAL, + ARM64 +} diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index ab1e97f1..1427e0e0 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a12482ca..c54a2273 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -192,7 +192,9 @@ Not ready • Tap to download Ready to share Sharing source: this installed APK + Sharing source: this installed APK • ARM64 devices only Sharing source: verified GitHub universal APK + Get universal Downloading… %1$d%% Update available Prepare diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt new file mode 100644 index 00000000..c4225288 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt @@ -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() + 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) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotPermissionsTest.kt b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotPermissionsTest.kt new file mode 100644 index 00000000..a7914fe0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotPermissionsTest.kt @@ -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(), HotspotPermissions.requiredForSdk(28)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt index 60c57198..6d6b1b1f 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -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))) diff --git a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt index c30054fa..c9efe3d6 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt @@ -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 { diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 414bfbae..00fdc98e 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -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/.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-b \ + --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-b --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 `-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 scan --extra timeout_ms=30000 +python3 tools/release_gate/mesh_lab.py cmd --serial handshake --extra peer= +python3 tools/release_gate/mesh_lab.py cmd --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 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/.json`. + +Unlike the release gate, this harness is a development aid: it prints raw +diagnostics and does not produce a privacy-checked approval bundle. diff --git a/docs/wear-os-implementation-plan.md b/docs/wear-os-implementation-plan.md new file mode 100644 index 00000000..cf89ac36 --- /dev/null +++ b/docs/wear-os-implementation-plan.md @@ -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 + (``, 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 --serial-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 install -r -g wear/build/outputs/apk/debug/wear-debug.apk +adb -s shell monkey -p com.bitchat.watch -c android.intent.category.LAUNCHER 1 + +# Screencap (design checks) +adb -s exec-out screencap -p > watch.png + +# Full interop suite (phone + watch) +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a --serial-watch \ + --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 --serial-watch --out /tmp/meshlab-evidence + +# Ad-hoc test-hook commands (watch) +adb -s shell am broadcast -a com.bitchat.watch.TEST_HOOK \ + -n com.bitchat.watch/.testhook.WearTestHookReceiver --es cmd state --es id s1 +adb -s 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`. diff --git a/docs/wear/screenshots/chat.png b/docs/wear/screenshots/chat.png new file mode 100644 index 00000000..1bb541df Binary files /dev/null and b/docs/wear/screenshots/chat.png differ diff --git a/docs/wear/screenshots/onboarding-nickname.png b/docs/wear/screenshots/onboarding-nickname.png new file mode 100644 index 00000000..7526337d Binary files /dev/null and b/docs/wear/screenshots/onboarding-nickname.png differ diff --git a/docs/wear/screenshots/people.png b/docs/wear/screenshots/people.png new file mode 100644 index 00000000..34513fc2 Binary files /dev/null and b/docs/wear/screenshots/people.png differ diff --git a/docs/wear/screenshots/voice-notes.png b/docs/wear/screenshots/voice-notes.png new file mode 100644 index 00000000..baabd834 Binary files /dev/null and b/docs/wear/screenshots/voice-notes.png differ diff --git a/docs/wear/screenshots/voice-recording.png b/docs/wear/screenshots/voice-recording.png new file mode 100644 index 00000000..75c455c9 Binary files /dev/null and b/docs/wear/screenshots/voice-recording.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5d5a03f..fa19edb4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a58af94..ffd88519 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,4 +15,5 @@ dependencyResolutionManagement { rootProject.name = "bitchat-android" include(":app") +include(":wear") // Using published Arti AAR; local module not included diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py new file mode 100644 index 00000000..214d4af0 --- /dev/null +++ b/tools/release_gate/mesh_lab.py @@ -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/.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 address↔peer 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()) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 00000000..03ae1431 --- /dev/null +++ b/wear/build.gradle.kts @@ -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("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("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().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) +} diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro new file mode 100644 index 00000000..001b0a09 --- /dev/null +++ b/wear/proguard-rules.pro @@ -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 ; +} + +# 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 diff --git a/wear/src/debug/AndroidManifest.xml b/wear/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..20c0f07f --- /dev/null +++ b/wear/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt new file mode 100644 index 00000000..c43ef258 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -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): 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 + } + } +} diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt new file mode 100644 index 00000000..080185d0 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt @@ -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 --es id [command extras...] + * + * Result is written to cache/testhook/results/.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() + } +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..531286cf --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt new file mode 100644 index 00000000..a6e8a164 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt @@ -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() + + 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 + } + } +} diff --git a/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt new file mode 100644 index 00000000..bd640a84 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -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 +} diff --git a/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt b/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt new file mode 100644 index 00000000..2b491274 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt @@ -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 +} diff --git a/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt new file mode 100644 index 00000000..f480cfdd --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt @@ -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) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt new file mode 100644 index 00000000..7770a6f4 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -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(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 = 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.Chat) } + val backStack = remember { mutableStateListOf() } + + 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") + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt new file mode 100644 index 00000000..71d707f1 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -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 = + 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() + + 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 = 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 = 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 = 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 = meshCore.getPeerNicknames() + + fun getPeerRSSI(): Map = 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() + } +} diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt new file mode 100644 index 00000000..ab69375c --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt @@ -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>() + + 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) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt new file mode 100644 index 00000000..b0edf915 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt @@ -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): Int = peers.distinct().size +} diff --git a/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt new file mode 100644 index 00000000..d1fdac0e --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt @@ -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 + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt new file mode 100644 index 00000000..03943ea0 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt @@ -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 { + 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 +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt new file mode 100644 index 00000000..7111cd1c --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -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) + ) + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt new file mode 100644 index 00000000..5f740f12 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -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, + 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, + 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(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 diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt new file mode 100644 index 00000000..7c902ea5 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -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(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( + 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) diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt new file mode 100644 index 00000000..689251a1 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -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(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( + 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) + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt new file mode 100644 index 00000000..68710025 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt @@ -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) + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt new file mode 100644 index 00000000..2a4bc6b6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt @@ -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) + ) +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt new file mode 100644 index 00000000..17568387 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -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) + ) + } + } + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt new file mode 100644 index 00000000..f701a887 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -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 { (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) + ) + } + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt new file mode 100644 index 00000000..2b79c98f --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt @@ -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) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt new file mode 100644 index 00000000..f73891f9 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt @@ -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 + ) + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt new file mode 100644 index 00000000..7080c933 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -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) } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt new file mode 100644 index 00000000..16dc2c0a --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt @@ -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>(emptyMap()) + val unreadDms: StateFlow> = _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 +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt new file mode 100644 index 00000000..6e8fc8e2 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt @@ -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)) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt new file mode 100644 index 00000000..c2fbf983 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -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" +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..5ae88981 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt @@ -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 +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt new file mode 100644 index 00000000..4d8c390b --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt @@ -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 + ) +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt new file mode 100644 index 00000000..3f928aba --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt @@ -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 + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt new file mode 100644 index 00000000..ff66567d --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt @@ -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, + ) +} diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..b63113ef --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..dbc4ad64 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_monochrome.xml b/wear/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..a9e8c8af --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_notification.xml b/wear/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..bc355f16 --- /dev/null +++ b/wear/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/font/geist_mono_bold.ttf b/wear/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 00000000..90eb8a86 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_bold.ttf differ diff --git a/wear/src/main/res/font/geist_mono_medium.ttf b/wear/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 00000000..ff49ece5 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_medium.ttf differ diff --git a/wear/src/main/res/font/geist_mono_regular.ttf b/wear/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 00000000..50c9d5a6 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_regular.ttf differ diff --git a/wear/src/main/res/font/geist_mono_semibold.ttf b/wear/src/main/res/font/geist_mono_semibold.ttf new file mode 100644 index 00000000..1b21724d Binary files /dev/null and b/wear/src/main/res/font/geist_mono_semibold.ttf differ diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/values-v31/styles.xml b/wear/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..aa520d34 --- /dev/null +++ b/wear/src/main/res/values-v31/styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 00000000..2a2ef2f3 --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + bitchat + Mesh network + Keeps the Bluetooth mesh running in the background + + Mesh running — %1$d peer + Mesh running — %1$d peers + + Direct messages + Encrypted direct-message alerts + New encrypted message + Unlock to view the message + + %1$d new message + %1$d new messages + + diff --git a/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt new file mode 100644 index 00000000..ab84d5c7 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt @@ -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"))) + } +}