From 43a88808a50e5268dd704c166c00b3accdff028c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:49 +0200 Subject: [PATCH] wear: M2/M3/M4/M6 code - BLE mesh service, chat/DM/people UI, test hook, mesh_lab watch support (hardware verification pending) --- docs/wear-os-implementation-plan.md | 8 +- tools/release_gate/mesh_lab.py | 129 +++++-- wear/build.gradle.kts | 1 + wear/src/debug/AndroidManifest.xml | 12 + .../watch/testhook/WearTestHookDriver.kt | 341 ++++++++++++++++++ .../watch/testhook/WearTestHookReceiver.kt | 61 ++++ wear/src/main/AndroidManifest.xml | 6 + .../bitchat/watch/BitchatWatchApplication.kt | 11 + .../java/com/bitchat/watch/MainActivity.kt | 164 ++++++++- .../com/bitchat/watch/mesh/WearMeshService.kt | 319 ++++++++++++++++ .../service/WearMeshForegroundService.kt | 78 ++++ .../java/com/bitchat/watch/ui/ChatScreen.kt | 300 +++++++++++++++ .../java/com/bitchat/watch/ui/DmScreen.kt | 153 ++++++++ .../bitchat/watch/ui/NicknameSetupScreen.kt | 100 +++++ .../com/bitchat/watch/ui/PeerDebugScreen.kt | 98 +++++ .../java/com/bitchat/watch/ui/PeopleScreen.kt | 134 +++++++ .../com/bitchat/watch/ui/WearChatState.kt | 32 ++ wear/src/main/res/values/strings.xml | 3 + 18 files changed, 1914 insertions(+), 36 deletions(-) create mode 100644 wear/src/debug/AndroidManifest.xml create mode 100644 wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt create mode 100644 wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt create mode 100644 wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt create mode 100644 wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt create mode 100644 wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt create mode 100644 wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt diff --git a/docs/wear-os-implementation-plan.md b/docs/wear-os-implementation-plan.md index 458b6a8c..e878565f 100644 --- a/docs/wear-os-implementation-plan.md +++ b/docs/wear-os-implementation-plan.md @@ -8,11 +8,11 @@ |-----------|-------|--------| | M0 | Scaffolding & plan document | done | | M1 | Shared core compiles on Wear | done | -| M2 | BLE transport & background service on watch | pending | -| M3 | Global chat | pending | -| M4 | Noise DMs & people screen | pending | +| M2 | BLE transport & background service on watch | in-progress (code complete, hardware verification pending — watch off ADB) | +| M3 | Global chat | in-progress (code complete, hardware verification pending) | +| M4 | Noise DMs & people screen | in-progress (code complete, hardware verification pending) | | M5 | File/image receive & display — **DEFERRED** (post-M7, later day) | deferred | -| M6 | ADB test hook & mesh_lab interop | pending | +| M6 | ADB test hook & mesh_lab interop | in-progress (code complete, interop run pending) | | M7 | Polish & final design pass | pending | --- diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 821ce844..ddbe4656 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -44,6 +44,10 @@ 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", @@ -55,6 +59,13 @@ PERMISSIONS = [ "android.permission.RECORD_AUDIO", ] +WATCH_PERMISSIONS = [ + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.POST_NOTIFICATIONS", +] + class MeshLabError(Exception): pass @@ -65,11 +76,23 @@ def _shell(serial: str, command: str) -> str: class Device: - """One ADB-connected phone running a debug build with the test hook.""" + """One ADB-connected device running a debug build with the test hook.""" - def __init__(self, serial: str, alias: str): + 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, + ): self.serial = serial self.alias = alias + self.package = package + self.hook_action = hook_action + self.hook_component = hook_component + self.permissions = permissions # -- app lifecycle ------------------------------------------------------ @@ -82,23 +105,23 @@ class Device: raise MeshLabError(f"[{self.alias}] install failed: {result.stdout} {result.stderr}") def grant_permissions(self) -> None: - for perm in PERMISSIONS: + for perm in self.permissions: subprocess.run( - [find_adb(), "-s", self.serial, "shell", "pm", "grant", APPLICATION_ID, perm], + [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 {APPLICATION_ID}") - output = _shell(self.serial, f"pm clear {APPLICATION_ID}") + _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 {APPLICATION_ID}") + _shell(self.serial, f"am force-stop {self.package}") def launch(self) -> None: - _shell(self.serial, f"monkey -p {APPLICATION_ID} -c android.intent.category.LAUNCHER 1") + _shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1") time.sleep(3) def wake(self) -> None: @@ -151,18 +174,19 @@ class Device: ) if result.returncode != 0: raise MeshLabError(f"[{self.alias}] push failed: {result.stderr}") - target = f"{APP_FIXTURE_DIR}/{fname}" + fixture_dir = f"/data/data/{self.package}/cache/fixtures" + target = f"{fixture_dir}/{fname}" _shell( self.serial, - f"run-as {APPLICATION_ID} mkdir -p {APP_FIXTURE_DIR} && " - f"cat {tmp} | run-as {APPLICATION_ID} sh -c 'cat > {target}' && rm -f {tmp}", + 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 {APPLICATION_ID} rm -rf cache/files/incoming cache/images/incoming", + f"run-as {self.package} rm -rf cache/files/incoming cache/images/incoming", ) # -- test hook commands ------------------------------------------------- @@ -170,11 +194,11 @@ class Device: 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 {APPLICATION_ID} rm -f {RESULTS_DIR}/{cmd_id}.json") + _shell(self.serial, f"run-as {self.package} rm -f {RESULTS_DIR}/{cmd_id}.json") args = [ - "am", "broadcast", "-a", TEST_HOOK_ACTION, - "-n", TEST_HOOK_COMPONENT, + "am", "broadcast", "-a", self.hook_action, + "-n", self.hook_component, "--es", "cmd", cmd, "--es", "id", cmd_id, "--el", "timeout_ms", str(timeout_ms), @@ -199,7 +223,7 @@ class Device: deadline = time.monotonic() + (timeout_ms + 60_000) / 1000 while time.monotonic() < deadline: try: - raw = _shell(self.serial, f"run-as {APPLICATION_ID} cat {RESULTS_DIR}/{cmd_id}.json") + 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: @@ -217,6 +241,30 @@ class Device: 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, + ) + + def wake(self) -> None: + _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 = { @@ -243,8 +291,17 @@ def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = N # MARK: - setup -def setup_pair(a: Device, b: Device, apk: Path | None, nickname_a: str, nickname_b: str) -> None: - for device, nickname in ((a, nickname_a), (b, nickname_b)): +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: @@ -558,13 +615,19 @@ SCENARIOS = { "identity_reset": scenario_identity_reset, } +# Scenarios supported when device B is a watch (file transfer deferred on the watch). +WATCH_SCENARIOS = ["dm", "broadcast", "raw", "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": - evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in SCENARIOS} + evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in supported} + 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" @@ -587,15 +650,18 @@ def build_parser() -> argparse.ArgumentParser: setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover") setup.add_argument("--serial-a", required=True) - setup.add_argument("--serial-b", 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", 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") @@ -606,19 +672,30 @@ def build_parser() -> argparse.ArgumentParser: 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( - Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"), - args.apk, args.nickname_a, args.nickname_b, + 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": - evidence = run_scenario( - args.name, Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"), args.out - ) + 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": diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index f2da0a8e..1ac19adc 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -158,6 +158,7 @@ dependencies { 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) 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..10c0350d --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -0,0 +1,341 @@ +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) + "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") + val address = mesh.getDeviceAddressForPeer(peerID) + ?: 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: - 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 index 231bc058..06fdd4e4 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + + 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 + } + else -> WearNavHost() + } + } + } + } + + override fun onResume() { + super.onResume() + refreshState() + } + + private fun refreshState() { + hasPermissions = requiredPermissions().all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + bluetoothEnabled = adapter?.isEnabled == true + if (hasPermissions && bluetoothEnabled) { + startMeshService() + } + } + + private fun startMeshService() { + val mesh = WearMeshService.getOrCreate(applicationContext) + mesh.onPrivateMessage = { message -> + message.senderPeerID?.let { WearChatState.onPrivateMessageArrived(it) } + } + startForegroundService(Intent(this, WearMeshForegroundService::class.java)) + } + + 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) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.POST_NOTIFICATIONS) } } } } @Composable -fun PlaceholderScreen() { +fun WearNavHost() { + 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() } + + when (val current = screen) { + is WearScreen.Chat -> ChatScreen(onOpenPeople = { navigate(WearScreen.People) }) + is WearScreen.People -> PeopleScreen(onOpenDm = { navigate(WearScreen.Dm(it)) }) + is WearScreen.Dm -> DmScreen(peerID = current.peerID) + } +} + +@Composable +fun PermissionRequestScreen(onGranted: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { onGranted() } + Column( modifier = Modifier .fillMaxSize() - .padding(24.dp), + .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = "bitchat", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary ) Text( - text = "mesh initializing", + text = "needs bluetooth to mesh with nearby devices", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp) + 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..1cf1bc47 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -0,0 +1,319 @@ +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.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 val 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, _ -> + routed.peerID?.let { pid -> + markDirectFromRelay(pid, routed.relayAddress) + 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) + } + } + } + } + + private fun markDirectFromRelay(peerID: String, relayAddress: String?) { + if (relayAddress == null) return + try { + if (connectionManager.addressPeerMap[relayAddress] == peerID || + connectionManager.addressPeerMap.containsValue(peerID) + ) { + meshCore.setDirectConnection(peerID, true) + } + } 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 + } + 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 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/service/WearMeshForegroundService.kt b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt new file mode 100644 index 00000000..855fea41 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt @@ -0,0 +1,78 @@ +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.ServiceInfo +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.bitchat.watch.MainActivity +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService + +/** + * 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 + } + + override fun onCreate() { + super.onCreate() + createChannel() + startForeground() + WearMeshService.getOrCreate(applicationContext).startServices() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + override fun onDestroy() { + WearMeshService.peek()?.stopServices() + 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() { + val launchIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.mesh_notification_text)) + .setContentIntent(launchIntent) + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + } +} 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..8d3c1b56 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -0,0 +1,300 @@ +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.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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.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.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +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.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.IconButton +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.DeliveryStatus +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 +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun ChatScreen(onOpenPeople: () -> Unit) { + val messages by AppStateStore.publicMessages.collectAsState() + val peers by AppStateStore.peers.collectAsState() + val mesh = WearMeshService.peek() + val myPeerID = mesh?.myPeerID ?: "" + val listState = rememberScalingLazyListState() + val palette = LocalBitchatPalette.current + val haptics = LocalHapticFeedback.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) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + previousCount = messages.size + } + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + item { + ChatHeader( + peerCount = peers.size, + onOpenPeople = onOpenPeople + ) + } + if (messages.isEmpty()) { + item { + Text( + text = "no messages yet\nsay hi to the mesh", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } + items(messages, key = { it.id }) { message -> + MessageItem(message = message, myPeerID = myPeerID) + } + item { + ChatComposer( + onSend = { text -> + mesh?.let { sendPublicMessage(it, text) } + } + ) + } + } + } +} + +@Composable +private fun ChatHeader(peerCount: Int, onOpenPeople: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "bitchat", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = " · $peerCount online >", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onOpenPeople() } + ) + } +} + +@Composable +fun MessageItem(message: BitchatMessage, myPeerID: String) { + val palette = LocalBitchatPalette.current + val isSelf = message.senderPeerID == myPeerID + val senderColor = when { + isSelf -> palette.accentOrange + else -> colorForPeer(message.sender + (message.senderPeerID ?: ""), palette) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 3.dp) + ) { + 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 + ) + } + Text( + text = message.content, + style = ChatVisualTokens.MessageBodyStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 1.dp) + ) + } +} + +@Composable +fun ChatComposer(onSend: (String) -> Unit) { + val palette = LocalBitchatPalette.current + var text by remember { mutableStateOf("") } + + val dictationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val spoken = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + if (!spoken.isNullOrBlank()) { + onSend(spoken.trim()) + text = "" + } + } + } + + fun send() { + val trimmed = text.trim() + if (trimmed.isNotEmpty()) { + onSend(trimmed) + text = "" + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = text, + onValueChange = { text = it }, + textStyle = ChatVisualTokens.MessageBodyStyle.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { send() }), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(18.dp)) + .background(palette.inputSurface) + .padding(horizontal = 12.dp, vertical = 8.dp), + decorationBox = { innerTextField -> + Box { + if (text.isEmpty()) { + Text( + text = "message", + style = ChatVisualTokens.MessageBodyStyle, + color = palette.textTertiary + ) + } + innerTextField() + } + } + ) + 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 + .padding(start = 4.dp) + .size(34.dp) + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = "dictate", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { send() }, + enabled = text.isNotBlank(), + modifier = Modifier + .padding(start = 2.dp) + .size(34.dp) + .clip(CircleShape) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "send", + tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary + else palette.textTertiary + ) + } + } +} + +private 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 + ) + ) +} + +private fun formatTime(date: Date): String = + SimpleDateFormat("HH:mm", Locale.getDefault()).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..6a299055 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -0,0 +1,153 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +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 +import java.util.Date + +@Composable +fun DmScreen(peerID: String) { + val privateMessages by AppStateStore.privateMessages.collectAsState() + val messages = privateMessages[peerID] ?: emptyList() + val mesh = WearMeshService.peek() + val myPeerID = mesh?.myPeerID ?: "" + val palette = LocalBitchatPalette.current + val listState = rememberScalingLazyListState() + val haptics = LocalHapticFeedback.current + + val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8) + var sessionEstablished by remember { + mutableStateOf(mesh?.hasEstablishedSession(peerID) == true) + } + + DisposableEffect(peerID) { + WearChatState.openDm(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) + } + } + + var previousCount by remember { mutableStateOf(messages.size) } + LaunchedEffect(messages.size) { + if (messages.size > previousCount) { + val last = messages.lastOrNull() + if (last != null && last.senderPeerID != myPeerID) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + previousCount = messages.size + } + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = nickname, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = colorForPeer(nickname + peerID, palette) + ) + Text( + text = if (sessionEstablished) " · noise ✓" else " · handshaking…", + style = MaterialTheme.typography.bodySmall, + color = if (sessionEstablished) MaterialTheme.colorScheme.primary + else palette.textTertiary + ) + } + } + if (messages.isEmpty()) { + item { + Text( + text = if (sessionEstablished) "encrypted channel ready\nsay hi" + else "setting up encryption…", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } + items(messages, key = { it.id }) { message -> + MessageItem(message = message, myPeerID = myPeerID) + } + item { + ChatComposer( + onSend = { text -> + mesh?.let { sendPrivateMessage(it, peerID, nickname, text) } + } + ) + } + } + } +} + +private fun sendPrivateMessage( + mesh: WearMeshService, + peerID: String, + recipientNickname: String, + content: String +) { + mesh.sendPrivateMessage(content, peerID, recipientNickname) + AppStateStore.addPrivateMessage( + peerID, + BitchatMessage( + sender = mesh.nickname, + content = content, + timestamp = Date(), + isPrivate = true, + recipientNickname = recipientNickname, + senderPeerID = mesh.myPeerID, + deliveryStatus = DeliveryStatus.Sent + ) + ) +} 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..e6493f96 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt @@ -0,0 +1,100 @@ +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.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.graphics.SolidColor +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +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 + +@Composable +fun NicknameSetupScreen( + initialNickname: String, + onConfirm: (String) -> Unit +) { + val palette = LocalBitchatPalette.current + var name by remember { mutableStateOf(initialNickname) } + + 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 = "pick a nickname", + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp, bottom = 10.dp) + ) + BasicTextField( + value = name, + onValueChange = { name = it.take(24) }, + 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 = { + if (name.isNotBlank()) onConfirm(name.trim()) + }), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(palette.inputSurface) + .padding(horizontal = 12.dp, vertical = 8.dp), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.Center) { + if (name.isEmpty()) { + Text( + text = "nickname", + style = ChatVisualTokens.MessageBodyStyle, + color = palette.textTertiary + ) + } + innerTextField() + } + } + ) + Button( + onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, + enabled = name.isNotBlank(), + modifier = Modifier.padding(top = 10.dp) + ) { + Text("join the mesh") + } + } +} 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..81183be9 --- /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..52c35332 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -0,0 +1,134 @@ +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.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.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) { + 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() + val rssi = mesh?.getPeerRSSI() ?: emptyMap() + + 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) + ) + } + } + items(peers, key = { it }) { peerID -> + val nick = nicknames[peerID] ?: peerID.take(8) + val unreadCount = unread[peerID] ?: 0 + val encrypted = mesh?.hasEstablishedSession(peerID) == true + PersonRow( + nickname = nick, + peerID = peerID, + rssi = rssi[peerID], + encrypted = encrypted, + unreadCount = unreadCount, + onClick = { onOpenDm(peerID) } + ) + } + } + } +} + +@Composable +private fun PersonRow( + nickname: String, + peerID: String, + rssi: Int?, + 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)) { + Text( + text = nickname, + style = ChatVisualTokens.SenderStyle, + color = colorForPeer(nickname + peerID, palette), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = buildString { + append(if (encrypted) "noise ✓" else "tap to chat") + rssi?.let { append(" · ${it}dBm") } + }, + style = ChatVisualTokens.SystemActionStyle, + color = if (encrypted) MaterialTheme.colorScheme.primary else palette.textTertiary + ) + } + if (unreadCount > 0) { + Text( + text = "$unreadCount new", + style = ChatVisualTokens.SystemActionStyle, + color = palette.accentOrange, + modifier = Modifier.padding(start = 6.dp) + ) + } + } + } +} 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..bc6de4b7 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt @@ -0,0 +1,32 @@ +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 openDmPeer: String? = null + + fun onPrivateMessageArrived(peerID: String) { + if (openDmPeer == peerID) return + _unreadDms.value = _unreadDms.value + (peerID to ((_unreadDms.value[peerID] ?: 0) + 1)) + } + + fun openDm(peerID: String) { + openDmPeer = peerID + _unreadDms.value = _unreadDms.value - peerID + } + + fun closeDm() { + openDmPeer = null + } + + fun unreadCount(peerID: String): Int = _unreadDms.value[peerID] ?: 0 +} diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index c89fc442..7ce8f2b8 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ bitchat + Mesh network + Keeps the Bluetooth mesh running in the background + Bluetooth mesh active