diff --git a/docs/wear-os-implementation-plan.md b/docs/wear-os-implementation-plan.md index 94c04158..f0b4115c 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 | 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) | +| M2 | BLE transport & background service on watch | done | +| M3 | Global chat | done | +| M4 | Noise DMs & people screen | done | | M5 | File/image receive & display — **DEFERRED** (post-M7, later day) | deferred | -| M6 | ADB test hook & mesh_lab interop | in-progress (code complete, interop run pending) | +| M6 | ADB test hook & mesh_lab interop | done | | M7 | Polish & final design pass | in-progress (animations/transitions done; power audit & screencap review pending watch) | --- @@ -172,6 +172,15 @@ AppStateStore) compiles into `:wear`; both modules' unit tests pass; `app/src/` **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). --- @@ -186,6 +195,15 @@ AppStateStore) compiles into `:wear`; both modules' unit tests pass; `app/src/` **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. --- @@ -200,6 +218,11 @@ a second phone that is only connected through the first (relay proof); screencap **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). --- @@ -236,6 +259,15 @@ scope.) **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`. --- diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index ddbe4656..163c9998 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -86,6 +86,7 @@ class Device: 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 @@ -93,6 +94,7 @@ class Device: self.hook_action = hook_action self.hook_component = hook_component self.permissions = permissions + self.activity_component = activity_component # -- app lifecycle ------------------------------------------------------ @@ -121,8 +123,26 @@ class Device: _shell(self.serial, f"am force-stop {self.package}") def launch(self) -> None: - _shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1") - time.sleep(3) + """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). @@ -257,9 +277,14 @@ class WatchDevice(Device): 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") @@ -461,19 +486,33 @@ def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None: 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. + 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 in ((a, id_b), (b, id_a)): - result = device.cmd("connect", timeout_ms=45_000, peer=peer) - if result.get("status") == "ok" and result.get("direct"): - continue - # 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 not match or not match.get("direct"): - raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={result}") + 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: @@ -625,7 +664,16 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: 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 supported} + 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: diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 1ac19adc..75df1a45 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -93,6 +93,10 @@ val sharedSourceExcludes = listOf( // (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") { diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt index 10c0350d..484f9fe3 100644 --- a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -122,8 +122,18 @@ object WearTestHookDriver { 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)") + // 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) { 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/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index 1cf1bc47..bb512cf6 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -6,6 +6,7 @@ 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 @@ -78,8 +79,22 @@ class WearMeshService private constructor(private val context: Context) { 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 -> - markDirectFromRelay(pid, routed.relayAddress) try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } @@ -186,17 +201,6 @@ class WearMeshService private constructor(private val context: Context) { } } - 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 { diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index d80474b6..3a305dd3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -81,49 +81,53 @@ fun ChatScreen(onOpenPeople: () -> Unit) { if (last != null && last.senderPeerID != myPeerID) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) } - // Keep the newest message visible (header + messages + composer indices) + // Keep the newest message visible (header is index 0, messages follow) if (messages.isNotEmpty()) { - listState.animateScrollToItem(messages.size + 1) + listState.animateScrollToItem(messages.size) } } previousCount = messages.size } ScreenScaffold(scrollState = listState) { - ScalingLazyColumn( - state = listState, - modifier = Modifier.fillMaxSize() - ) { - item { - ChatHeader( - peerCount = peers.size, - unreadDms = unreadDms.values.sum(), - onOpenPeople = onOpenPeople - ) - } - if (messages.isEmpty()) { + // Composer pinned outside the ScalingLazyColumn: edge items in a scaling list are + // shrunk/faded and hard to tap reliably on a round screen. + Box(modifier = Modifier.fillMaxSize()) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp) + ) { 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) + ChatHeader( + peerCount = peers.size, + unreadDms = unreadDms.values.sum(), + onOpenPeople = onOpenPeople ) } - } - items(messages, key = { it.id }) { message -> - MessageItem(message = message, myPeerID = myPeerID) - } - item { - ChatComposer( - onSend = { text -> - mesh?.let { sendPublicMessage(it, text) } + 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) + } } + ChatComposer( + onSend = { text -> + mesh?.let { sendPublicMessage(it, text) } + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } } @@ -220,7 +224,7 @@ fun MessageItem(message: BitchatMessage, myPeerID: String) { } @Composable -fun ChatComposer(onSend: (String) -> Unit) { +fun ChatComposer(onSend: (String) -> Unit, modifier: Modifier = Modifier) { val palette = LocalBitchatPalette.current var text by remember { mutableStateOf("") } @@ -247,14 +251,16 @@ fun ChatComposer(onSend: (String) -> Unit) { } Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 6.dp), + .background(MaterialTheme.colorScheme.background) + .padding(start = 24.dp, end = 24.dp, top = 4.dp, bottom = 14.dp), verticalAlignment = Alignment.CenterVertically ) { BasicTextField( value = text, onValueChange = { text = it }, + singleLine = true, textStyle = ChatVisualTokens.MessageBodyStyle.copy( color = MaterialTheme.colorScheme.onSurface ), diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index 6a299055..b1f21a31 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -1,5 +1,6 @@ package com.bitchat.watch.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -77,56 +78,58 @@ fun DmScreen(peerID: String) { } 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()) { + Box(modifier = Modifier.fillMaxSize()) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 64.dp) + ) { item { - Text( - text = if (sessionEstablished) "encrypted channel ready\nsay hi" - else "setting up encryption…", - style = ChatVisualTokens.SystemActionStyle, - color = palette.textTertiary, - textAlign = TextAlign.Center, + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp) - ) + .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) } } - items(messages, key = { it.id }) { message -> - MessageItem(message = message, myPeerID = myPeerID) - } - item { - ChatComposer( - onSend = { text -> - mesh?.let { sendPrivateMessage(it, peerID, nickname, text) } - } - ) - } + ChatComposer( + onSend = { text -> + mesh?.let { sendPrivateMessage(it, peerID, nickname, text) } + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } }