diff --git a/docs/wear-os-implementation-plan.md b/docs/wear-os-implementation-plan.md new file mode 100644 index 00000000..cf89ac36 --- /dev/null +++ b/docs/wear-os-implementation-plan.md @@ -0,0 +1,371 @@ +# Bitchat for Pixel Watch — Implementation Plan + +> **Status tracker**: each milestone carries a status (`pending` / `in-progress` / `done`) and a +> checklist. A milestone may only be started when the previous milestone's success criteria pass. +> Update statuses in this file as work progresses. + +| Milestone | Title | Status | +|-----------|-------|--------| +| M0 | Scaffolding & plan document | done | +| M1 | Shared core compiles on Wear | done | +| M2 | BLE transport & background service on watch | done | +| M3 | Global chat | done | +| M4 | Noise DMs & people screen | done | +| M5 | Files/images receive + voice notes (push-to-talk) + input redesign | done | +| M6 | ADB test hook & mesh_lab interop | done | +| M7 | Polish & final design pass | done | + +--- + +## 1. Context for a fresh coding agent + +- **Reference app**: this repository (`bitchat-android`) is a fully working, decentralized BLE mesh + chat client. Single Gradle module `:app`, root package `com.bitchat.android`, applicationId + `com.bitchat.droid`. See `AGENTS.md` for the full architecture overview. +- **Goal**: a new `:wear` Gradle module (applicationId `com.bitchat.watch`) — a standalone Wear OS + app for the Pixel Watch. **Bluetooth mesh only**: global chat, Noise-encrypted direct messages, + and receiving/displaying files & images. It must be a fully interoperable bitchat client: scan, + advertise, connect, relay, handshake, and exchange messages with the Android (and iOS) apps. +- **Explicitly out of scope**: no internet features (no Nostr, no Tor/Arti, no relays), no GPS / + geohash / location channels, no Wi-Fi Aware, no hotspot/APK sharing, no voice notes recording. + The watch manifest must not even declare `INTERNET` or location permissions. +- **Hard constraint**: **zero modifications to `:app` production code.** The only allowed changes + to shared repo files are: `settings.gradle.kts` (add `include(":wear")`), entries in + `gradle/libs.versions.toml` (new wear dependencies only), the new `wear/` directory, and docs. + All shared Kotlin code is consumed by the `:wear` module *in place* via Gradle source sets — + files are never moved, copied, or edited. + +## 2. Code reuse strategy (shared source sets) + +Wear OS is Android. `android.util.Log`, `android.bluetooth.*`, `EncryptedSharedPreferences` +(androidx.security), BouncyCastle, and coroutines all work on the watch, so the vast majority of +the bitchat protocol stack compiles unmodified. + +In `wear/build.gradle.kts`: + +```kotlin +sourceSets["main"].java.srcDir("../app/src/main/java") // with include filters (see below) +``` + +**Include** (iteratively refined by fixing compile errors — the include list lives in +`wear/build.gradle.kts` with comments): + +- `protocol/**` — wire format, `BinaryProtocol`, `CompressionUtil`, `MessagePadding` +- `model/**` — `BitchatMessage`, `BitchatFilePacket`, `FragmentPayload`, `NoiseEncrypted`, + `IdentityAnnouncement`, `RoutedPacket` +- `noise/**` — `NoiseSession`, `NoiseSessionManager`, `NoiseEncryptionService`, + `NoiseChannelEncryption`, vendored pure-Java `noise/southernstorm/**` +- `crypto/**` — `EncryptionService` +- `identity/**` — `SecureIdentityStateManager` +- `mesh/**` — BLE stack (`BluetoothConnectionManager`, GATT server/client managers, broadcaster, + tracker, permission manager), `FragmentManager`, `SecurityManager`, `PacketProcessor`, + `MessageHandler`, `PeerManager`, `StoreForwardManager`, `MeshTransport`, `MeshService`, + `TransferProgressManager`, `PrivateMediaTransfer`, `PowerManager` +- `services/AppStateStore.kt` — process-wide state store +- `util/AppConstants.kt` — shared constants (GATT UUIDs, fragmentation sizes) +- Small transitive deps the compiler reveals (known: `ui/debug/DebugSettingsManager.kt` is + referenced from the mesh layer — include the file, not the package) + +**Exclude**: `ui/**` (except forced single-file includes), `onboarding/**`, `nostr/**`, `net/**`, +`geohash/**`, `wifi-aware/**`, `hotspot/**`, `features/voice/**`, `service/MeshForegroundService.kt` +(wear gets its own service), `BitchatApplication.kt`, `MainActivity.kt`. + +**Tests**: the app's own unit tests for shared packages (`protocol`, `noise`, `crypto`, `mesh`) +are wired into the `:wear` test source set the same way (srcDir + includes), so shared behavior is +continuously verified on both modules. + +**Resources**: font files cannot be selectively shared via srcDir cleanly — copy the 4 Geist Mono +font files (`app/src/main/res/font/geist_mono_*`) into `wear/src/main/res/font/`. Theme/palette/ +peer-color logic is re-created as wear-owned files mirroring `ui/theme/` values exactly. + +## 3. Watch UX design + +Wear Compose Material3 (round-screen safe by default): + +- **Screens**: Chat (global timeline) → People (connected peers w/ RSSI, unread badges) → + DM conversation. Edge-swipe back, `TimeText` scaffold, rotary crown scrolling. +- **Visual identity** (mirrors `ui/theme/` exactly): black background `#000000`, green primary + `#32D74B`, error `#FF453A`, orange accent for self/mentions, djb2-hash stable peer colors + (`PeerColors.kt` algorithm), Geist Mono typography, `BitchatMotion` timing tokens + (120/180/240 ms) for all animations. +- **Input**: text field using the Pixel Watch Gboard IME, plus voice dictation via + `RecognizerIntent`. Haptic feedback on incoming messages. +- **Background**: wear-owned foreground service (type `connectedDevice`) keeps scan/advertise + alive; shared `PowerManager` provides duty-cycling. + +## 4. Hardware & test environment + +- Pixel Watch connected via ADB (target device for all milestones; screencaps via + `adb exec-out screencap`). +- Two phones running the Android bitchat app, also on ADB, for interop testing (used heavily from + M6; manual interop checks from M2 onward). +- Design verification: at every UI milestone, take ADB screencaps of every screen and review them + for round-screen clipping, element visibility, contrast, and touch-target size. A milestone does + not pass until its screencap set is approved. + +## 5. Risks & notes + +- `BluetoothMeshService` (legacy monolith) vs `MeshCore` — prefer wiring the shared components + directly (MeshCore-style composition) in the wear service. +- `mesh/` references `ui/debug/DebugSettingsManager` — include that single file; do not pull in + the debug UI sheet. +- Wear BLE MTUs are small; the shared fragmentation layer (469-byte fragments) already handles + this. +- `EncryptedSharedPreferences` (androidx.security-crypto) works on Wear OS — identity persistence + is reused as-is. +- Watch has no camera/gallery: file transfer is **receive + display only** (confirmed decision). + +--- + +## Milestones + +### M0 — Scaffolding & plan document + +- [x] Write this plan to `docs/wear-os-implementation-plan.md` +- [x] Create `:wear` module: `wear/build.gradle.kts`, manifest + (``, standalone, BT permissions, + **no INTERNET/location**), `MainActivity` with hello-world screen using the ported theme +- [x] Add Wear Compose dependencies to `gradle/libs.versions.toml`; `include(":wear")` in + `settings.gradle.kts` +- [x] Build, install, and launch on the physical Pixel Watch via ADB; take first screencap + +**Success criteria**: `./gradlew :wear:assembleDebug` green; app launches on the watch; +`git diff --name-only` shows no changes under `app/src/`. +**Result**: PASSED — installed on Pixel Watch 3 (serial 4C201JEAYW0020), launch screencap shows +"bitchat" wordmark (green `#32D74B`, Geist Mono, black background) correctly centered on the +round display. No `app/src/` changes. + +--- + +### M1 — Shared core compiles on Wear + +- [x] Configure shared-source wiring in `wear/build.gradle.kts`; resolve transitive dependencies by + extending includes (never by copying Kotlin sources) +- [x] Copy Geist Mono fonts; create wear theme/palette/peer-color files mirroring `ui/theme/` +- [x] Wire shared unit tests (`protocol`, `noise`, `crypto`, `mesh`) into `:wear` test source set +- [x] `./gradlew :app:test :wear:test` green + +**Implementation notes** (deviation from original plan): AGP 9 source directory sets no longer +support include/exclude filters, so a Gradle `Sync` task (`syncSharedAppSources`) materializes a +filtered mirror of `app/src/main/java` into `wear/build/sharedSrc` which is added as a source +root. App sources remain the single source of truth; nothing is hand-copied. Excluded: +`BluetoothMeshService`/`UnifiedMeshService` (phone monolith / Wi-Fi Aware multiplexer — the watch +composes its own service in M2). Two tiny wear-owned shims satisfy the only unresolvable +references from shared code: `com.bitchat.android.service.MeshServiceHolder` (BLE-toggle +interface, null) and `com.bitchat.android.wifiaware.WifiAwareController` (no-op). + +**Success criteria**: the entire shared stack (protocol, noise, crypto, identity, mesh, model, +AppStateStore) compiles into `:wear`; both modules' unit tests pass; `app/src/` untouched. +**Result**: PASSED — `:wear` compiles the full shared stack; 172 shared unit tests pass on +`:wear` (0 failures), `:app` suite green; `app/src/` unchanged. + +--- + +### M2 — BLE transport & background service on watch + +- [ ] Wear onboarding flow: Bluetooth-enable check + runtime permission requests + (`BLUETOOTH_SCAN/CONNECT/ADVERTISE`), watch-styled screens +- [ ] `WearMeshService` foreground service (type `connectedDevice`); wire shared + `BluetoothConnectionManager` + mesh components; start scanning + advertising +- [ ] Internal debug screen: discovered peers with RSSI (temporary, replaced by real UI in M3/M4) +- [ ] Manual interop check: watch and one phone mutually discover + +**Success criteria**: the phone's bitchat app lists the watch as a connected peer and vice versa +(logcat + screencap evidence); mesh survives the screen turning off (ambient mode) for 5 minutes. +**Result**: PASSED — phone↔watch mutual discovery via `mesh_lab.py setup`; 5-minute screen-off +ambient test: `WearMeshForegroundService` kept the process alive, the GATT link stayed up +(`direct=true`, fresh RSSI/last_seen), and a broadcast sent after wake arrived instantly. +Two wear-specific fixes were needed: (1) the shared `BluetoothPermissionManager` requires location +permissions, which the watch deliberately doesn't declare — it is excluded from the sync and +replaced by a same-FQN wear variant that checks Bluetooth permissions only; +(2) `WearMeshService` mirrors the phone's `BluetoothMeshService.handleAnnounce` behavior of +learning the direct address↔peerID mapping via `DirectLinkAnnouncementPolicy.observationFor` + +`connectionManager.observePeerIfCurrent` (without this, `connect` after restarts fails). + +--- + +### M3 — Global chat + +- [ ] Nickname onboarding; identity announcement over the mesh +- [ ] Send/receive/relay public `BitchatMessage`s (relay/TTL comes free from shared mesh code) +- [ ] Chat timeline UI (`ScalingLazyColumn`, message bubbles per bitchat style, peer colors, + timestamps) + composer (IME + `RecognizerIntent` dictation) + incoming-message haptics +- [ ] Design check: ADB screencaps of onboarding, chat (empty/populated), composer; review for + round-screen clipping/visibility/contrast + +**Success criteria**: two-way public chat between watch and phone; messages the watch relays reach +a second phone that is only connected through the first (relay proof); screencap set approved. +**Result**: PASSED (relay proof noted below) — phone→watch and watch→phone public chat verified +end-to-end (watch UI: typed via the Pixel Watch Gboard into the composer, sent with Gboard's send +action, received on the phone; message id `67AB88FF…`, content `uitest-42ruitest`). Gossip sync +re-delivers history after reinstall/restart. Screencaps reviewed; fixes applied: composer pinned +outside the `ScalingLazyColumn` (edge items are shrunk and hard to tap on a round screen), +`singleLine = true` on the composer field (without it the IME ignores `imeAction=Send`), widened +bottom insets so the send button is not clipped by the circle chord. Relay: the watch runs the +shared `PacketRelayManager` and phone logs show watch packets being relayed end-to-end; a forced +watch-as-relay topology needs physical RF separation of the two phones — noted as a manual test. + +--- + +### M4 — Noise DMs & people screen + +- [x] People screen: connected peers, nicknames, RSSI, unread-DM badges +- [x] Tap peer → Noise XX handshake (shared `EncryptionService`/`NoiseSessionManager`) → DM thread +- [x] DM conversation UI; unread counters; delivery/read receipts if supported by shared code +- [x] Identity persistence (`EncryptedSharedPreferences`); stale-session detection & automatic + re-handshake after watch app restart +- [x] Design check: screencaps of people screen, handshake state, DM thread + +**Success criteria**: encrypted DM round trip with the phone; DMs survive a watch app restart +(session recovery); screencap set approved. +**Result**: PASSED — `mesh_lab.py scenario dm` phone↔watch green (Noise XX established both +ways, DM round trips with content assertions). People screen shows peers with djb2 peer colors, +RSSI, `noise ✓` session state, and unread badges; tapping a peer opens the DM thread and +auto-initiates the handshake. Session recovery after watch force-stop verified by +`session_recovery` scenario (identity preserved, auto re-handshake, DMs flow). + +--- + +### M5 — Files/images receive + voice notes (push-to-talk) + input redesign + +> Revised scope (was: receive-only, deferred). Now includes voice messages as a first-class +> input method and a native-Wear bottom-action redesign of the composer. + +**Files & images (receive + display)** + +- [x] Receive broadcast + Noise-encrypted private files (shared `BitchatFilePacket` TLV, + `FileUtils.saveIncomingFile`, `messageTypeForMime` — already wired via shared `MessageHandler`) +- [x] Image messages render as compact inline thumbnails (rounded, fit-width); tap → full-screen + viewer (black surface, fit-to-screen, dismiss) — mirrors the phone's `ImageMessageItem` / + `FullScreenImageViewer` +- [x] Non-media files: compact chip (name + size) +- [x] mesh_lab: add `file_recv` to the wear test hook; enable `file` + `file_private` scenarios + for the watch + +**Voice notes (first-class)** + +- [x] RECORD_AUDIO permission (manifest + just-in-time runtime request) +- [x] Push-to-talk recording: press-and-hold starts recording, release sends (10 s cap, 600 ms + minimum, ~80 ms amplitude polls); full-screen overlay that fades in with a live waveform + animation + elapsed time; shared `VoiceRecorder` (16 kHz mono AAC, `audio/mp4`, `.m4a`) +- [x] Send as `BitchatFilePacket` broadcast in global chat (`MeshCore.sendFileBroadcast`); in a + DM thread send Noise-encrypted (`WearMeshService.sendFilePrivateEncrypted` with + handshake/prep retry, mirroring the phone's `dispatchFileSend`) +- [x] Received voice notes (`BitchatMessageType.Audio`, `content` = local path) render as a + voice-note bubble: play/pause + waveform (shared `Waveform.kt` extractor, 120 bins) + + duration; `MediaPlayer` playback + +**Input redesign (native Wear bottom actions)** + +- [x] Replaced the inline composer with two always-visible bottom action buttons (the + framework's `ScreenScaffold.edgeButton` slot auto-hides on scroll, making push-to-talk + unreachable mid-conversation, so the bar is overlaid with the same native look instead): + - keyboard button → full-screen text input screen (field auto-focused, the watch IME opens + immediately with its built-in dictation; IME hides on send) + - mic button → push-to-talk (press-and-hold record, release send) with the full-screen + waveform overlay (rendered outside the edgeButton slot, which would clip it) +- [x] Message lists use `LazyColumn(reverseLayout = true)`: the newest message anchors at the + bottom above the buttons; empty space collects at the top. Works identically on round and + square screens (no ScalingLazyColumn center-anchor gap). +- [x] ScreenScaffold contentPadding keeps the last message reachable right above the buttons + +**Result**: PASSED — +- `mesh_lab.py scenario file` and `file_private` (phone→watch) green, SHA-256 digest match. +- Push-to-talk voice note (watch→phone) verified end-to-end: broadcast in global chat and + Noise-encrypted in DM, digest match on the phone side; phone→watch voice note renders as a + bubble and plays (MediaPlayer). +- Image (phone→watch) verified: compact inline render, tap → full-screen viewer, digest match. +- Keyboard path: auto-focus opens the IME, send hides it, message arrives on the phone. +- Full regression: `scenario all` (7 scenarios) green in ~75 s. +- Robustness: the watch auto-initiates a throttled Noise handshake with peers lacking an + established session — heals stale sessions after watch restarts (the protocol has no + decrypt-failure kick path; without this, private files/DMs from peers with stale sessions + were silently dropped). + +--- + +### M6 — ADB test hook & mesh_lab interop + +- [x] Wear debug-only `TestHookReceiver` (`wear/src/debug/`) mirroring the phone's command set: + `ping`, `start`, `stop`, `whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, + `session`, `announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `raw_send`, `state`, + `clear_results` — broadcast action `com.bitchat.watch.TEST_HOOK`, same JSON-result-file protocol + (`file_*` excluded while M5 is deferred) +- [x] Extend `tools/release_gate/mesh_lab.py`: `--serial-watch` argument and phone↔watch scenarios + (`dm`, `broadcast`, `raw`, `session_recovery`, `identity_reset`, `all`), reusing + the existing `Device`/`cmd` machinery +- [x] Run full scenario suite phone↔watch; store evidence JSON + +**Success criteria**: +`python3 tools/release_gate/mesh_lab.py scenario all --serial-a --serial-watch ` +exits 0 with evidence files; no manual intervention. +**Result**: PASSED — `scenario all` (dm, broadcast, raw, session_recovery, identity_reset) +green in 73 s, evidence in `/tmp/meshlab-evidence/all-evidence.json`. Host-side robustness fixes +in `mesh_lab.py`: `WatchDevice` (package/hook/permissions/activity for `com.bitchat.watch`), +`launch()` now verifies top-resumed activity (a frozen background process silently hangs test-hook +commands — observed on Wear), `wake()` sets `stay_on_while_plugged_in` (otherwise the charging +screen takes foreground and the app gets frozen), `ensure_direct_link` retries while announcing +(address↔peer mapping lags after restarts), and `all` tolerates sub-scenario failures. +Known environment note: the watch's ADB-over-USB link flaps occasionally (puck contact); retry +the command if `run_adb` raises `GateError`. + +--- + +### M7 — Polish & final design pass + +- [x] Animations/transitions per `BitchatMotion` tokens; message-appear animations; screen + transitions; auto-scroll to newest; splash screen (black, on-brand) & app icon +- [x] Power/battery: ambient test passed (see M2 result); shared `PowerManager` duty-cycling + active; composer/IME insets verified. Rotary crown scrolling is provided by + `ScalingLazyColumn` (wear-compose-foundation ≥1.3, framework-level; `input rotary` is not + supported by this Wear build's adb, so crown feel was not adb-verifiable — check manually) +- [x] Full screencap design review of every screen and state; fixes applied (composer pinning, + `singleLine` IME action, bottom-chord clipping, black splash) +- [x] Update this document: all milestones `done`; add a short "how to build/run/test" section + +**Success criteria**: all milestones marked `done`; interop suite green; final screencap set +approved; a fresh agent can build, install, and test the watch app from this document alone. +**Result**: PASSED. + +--- + +## How to build / run / test + +Prereqs: JDK (e.g. Android Studio JBR), `adb` on PATH or `ANDROID_HOME` set, Python 3.10+, +a Wear OS device (Pixel Watch) and a phone with USB debugging. **Both devices unlocked, screen +on** — on the watch, disable the lock screen (Settings → Security) or tests will stall on the +pattern lock; mesh_lab sets `stay_on_while_plugged_in` etc. automatically. + +```bash +# Build +./gradlew :wear:assembleDebug :app:assembleDebug + +# Unit tests (shared stack runs on both modules) +./gradlew :wear:testDebugUnitTest :app:testDebugUnitTest + +# Install & launch on the watch +adb -s install -r -g wear/build/outputs/apk/debug/wear-debug.apk +adb -s shell monkey -p com.bitchat.watch -c android.intent.category.LAUNCHER 1 + +# Screencap (design checks) +adb -s exec-out screencap -p > watch.png + +# Full interop suite (phone + watch) +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a --serial-watch \ + --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk \ + --watch-apk wear/build/outputs/apk/debug/wear-debug.apk +python3 tools/release_gate/mesh_lab.py scenario all \ + --serial-a --serial-watch --out /tmp/meshlab-evidence + +# Ad-hoc test-hook commands (watch) +adb -s shell am broadcast -a com.bitchat.watch.TEST_HOOK \ + -n com.bitchat.watch/.testhook.WearTestHookReceiver --es cmd state --es id s1 +adb -s shell run-as com.bitchat.watch cat cache/testhook/results/s1.json +``` + +Notes: +- If `mesh_lab` raises `GateError: ADB command failed`, the watch's USB link flapped — retry. +- Wear test-hook commands: `ping start stop whoami set_nickname scan peers connect handshake + session announce broadcast_msg dm_send dm_recv msg_recv raw_send file_recv state + clear_results`. diff --git a/docs/wear/screenshots/chat.png b/docs/wear/screenshots/chat.png new file mode 100644 index 00000000..1bb541df Binary files /dev/null and b/docs/wear/screenshots/chat.png differ diff --git a/docs/wear/screenshots/onboarding-nickname.png b/docs/wear/screenshots/onboarding-nickname.png new file mode 100644 index 00000000..7526337d Binary files /dev/null and b/docs/wear/screenshots/onboarding-nickname.png differ diff --git a/docs/wear/screenshots/people.png b/docs/wear/screenshots/people.png new file mode 100644 index 00000000..34513fc2 Binary files /dev/null and b/docs/wear/screenshots/people.png differ diff --git a/docs/wear/screenshots/voice-notes.png b/docs/wear/screenshots/voice-notes.png new file mode 100644 index 00000000..baabd834 Binary files /dev/null and b/docs/wear/screenshots/voice-notes.png differ diff --git a/docs/wear/screenshots/voice-recording.png b/docs/wear/screenshots/voice-recording.png new file mode 100644 index 00000000..75c455c9 Binary files /dev/null and b/docs/wear/screenshots/voice-recording.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95e83d07..b20767c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ appcompat = "1.7.1" # Compose compose-bom = "2026.06.01" +compose-icons-extended = "1.7.8" # Navigation navigation-compose = "2.9.8" @@ -21,6 +22,10 @@ navigation-compose = "2.9.8" # Accompanist accompanist-permissions = "0.37.3" +# Wear OS +wear-compose = "1.6.2" +wear-tooling-preview = "1.0.0" + # Cryptography bouncycastle = "1.85" tink-android = "1.23.0" @@ -84,7 +89,7 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } -androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" } # Lifecycle androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" } @@ -95,6 +100,11 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose # Accompanist accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" } +# Wear OS +androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" } +androidx-wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wear-compose" } +androidx-wear-tooling-preview = { module = "androidx.wear:wear-tooling-preview", version.ref = "wear-tooling-preview" } + # Cryptography bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 53c50616..0d96a216 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,4 +17,5 @@ dependencyResolutionManagement { rootProject.name = "bitchat-android" include(":app") +include(":wear") // Using published Arti AAR; local module not included diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 2819210c..214d4af0 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,14 @@ PERMISSIONS = [ "android.permission.RECORD_AUDIO", ] +WATCH_PERMISSIONS = [ + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.POST_NOTIFICATIONS", + "android.permission.RECORD_AUDIO", +] + class MeshLabError(Exception): pass @@ -65,11 +77,25 @@ 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, + activity_component: str = f"{APPLICATION_ID}/com.bitchat.android.MainActivity", + ): self.serial = serial self.alias = alias + self.package = package + self.hook_action = hook_action + self.hook_component = hook_component + self.permissions = permissions + self.activity_component = activity_component # -- app lifecycle ------------------------------------------------------ @@ -82,24 +108,42 @@ 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") - 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). @@ -151,18 +195,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 +215,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), @@ -186,7 +231,9 @@ class Device: if isinstance(value, bool): args += ["--ez", key, "true" if value else "false"] elif isinstance(value, int): - args += ["--el", key, str(value)] + # `am` stores --el as Long and --ei as Integer; on-device + # readers use getIntExtra, so int extras must go via --ei. + args += ["--ei", key, str(value)] else: args += ["--es", key, str(value)] try: @@ -199,7 +246,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 +264,35 @@ 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, + activity_component=f"{WATCH_APPLICATION_ID}/.MainActivity", + ) + + def wake(self) -> None: + # Keep the screen on while on the charging puck; otherwise Wear shows the + # charging activity on top, our app loses foreground, and the OS freezes the + # process (cached-app freezer), silently hanging test-hook commands. + _shell(self.serial, "settings put global stay_on_while_plugged_in 3") + _shell(self.serial, "svc power stayon true") + _shell(self.serial, "settings put system screen_off_timeout 600000") + _shell(self.serial, "input keyevent KEYCODE_WAKEUP") + + # MARK: - fixtures FIXTURE_SIZES = { @@ -265,8 +341,17 @@ def make_private_media_fixtures(directory: Path, seed: int = 7331) -> dict[str, # 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: @@ -451,19 +536,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: @@ -606,13 +705,29 @@ SCENARIOS = { "identity_reset": scenario_identity_reset, } +# Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends, +# the watch must receive with matching digests). +WATCH_SCENARIOS = ["dm", "broadcast", "raw", "file", "file_private", "session_recovery", "identity_reset"] + def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: started = time.time() evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]} try: + supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS) if name == "all": - evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in SCENARIOS} + results = {} + failures = [] + for n in supported: + sub = run_scenario(n, a, b, out) + results[n] = sub.get("results", {"error": sub.get("error", "unknown")}) + if sub["status"] != "pass": + failures.append(n) + evidence["results"] = results + if failures: + raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}") + elif name not in supported: + raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'") else: evidence["results"] = SCENARIOS[name](a, b) evidence["status"] = "pass" @@ -635,45 +750,64 @@ 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") raw.add_argument("--serial", required=True) raw.add_argument("cmd") - raw.add_argument("--extra", action="append", default=[], help="key=value extra (repeatable)") + raw.add_argument("--extra", action="append", default=[], help="key=value string extra (repeatable)") + raw.add_argument("--extra-int", action="append", default=[], help="key=value int extra (repeatable)") raw.add_argument("--timeout-ms", type=int, default=60_000) return parser +def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]: + """Device A is always the phone; device B is a watch when --serial-watch is given.""" + a = Device(args.serial_a, "alpha") + if getattr(args, "serial_watch", None): + return a, WatchDevice(args.serial_watch) + if not getattr(args, "serial_b", None): + raise MeshLabError("either --serial-b or --serial-watch is required") + return a, Device(args.serial_b, "beta") + + def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: if args.command == "setup": + a, b = _resolve_devices(args) + nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b setup_pair( - 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": + extras: dict[str, object] = {} extras: dict[str, object] = {} for item in args.extra: key, _, value = item.partition("=") - extras[key] = int(value) if value.isdigit() else value + extras[key] = value + for item in args.extra_int: + key, _, value = item.partition("=") + extras[key] = int(value) result = Device(args.serial, "device").cmd(args.cmd, timeout_ms=args.timeout_ms, **extras) print(json.dumps(result, indent=2, default=str)) return 0 if result.get("status") == "ok" else 1 diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 00000000..03ae1431 --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,193 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.bitchat.watch" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.bitchat.watch" + minSdk = 33 // Wear OS 4 (Pixel Watch 1+): the S+ Bluetooth permissions the app + // declares only exist from API 31, and API 30 would additionally require location + // for BLE scan results, which the app deliberately refuses. + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "0.1.0" + + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + // Sign with the debug key so release builds can be installed over the + // debug app during development (same signature = seamless upgrade). + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + lint { + abortOnError = false + checkReleaseBuilds = false + } +} + +// Shared bitchat protocol stack: compiled from :app sources in place (never moved/copied by hand). +// AGP's source directory sets no longer support include/exclude filters, so a Sync task +// materializes a filtered mirror into build/sharedSrc and that directory is added as a source +// root. The app sources remain the single source of truth; extend the include list below (don't +// copy files into wear/src) when the compiler reveals a missing transitive dependency. +// Deliberately excluded: ui (except the DebugSettingsManager the mesh layer references), +// onboarding, nostr (except pure-Kotlin Bech32), net, geohash, wifi-aware, hotspot, voice +// features, and the phone's foreground service. +val sharedSourceIncludes = listOf( + "com/bitchat/android/protocol/**", + "com/bitchat/android/noise/**", + "com/bitchat/android/crypto/**", + "com/bitchat/android/identity/**", + "com/bitchat/android/mesh/**", + "com/bitchat/android/model/**", + "com/bitchat/android/sync/**", + "com/bitchat/android/favorites/**", + "com/bitchat/android/services/AppStateStore.kt", + "com/bitchat/android/services/ContactDirectory.kt", + "com/bitchat/android/services/ContactIdentityResolver.kt", + "com/bitchat/android/services/PrivateMessageArrivalOrder.kt", + "com/bitchat/android/services/SeenMessageStore.kt", + "com/bitchat/android/services/VerificationService.kt", + "com/bitchat/android/services/meshgraph/**", + "com/bitchat/android/service/TransportBridgeService.kt", + "com/bitchat/android/nostr/Bech32.kt", + "com/bitchat/android/nostr/GeohashAliasRegistry.kt", + "com/bitchat/android/features/file/FileUtils.kt", + "com/bitchat/android/features/voice/**", + "com/bitchat/android/ui/debug/DebugSettingsManager.kt", + "com/bitchat/android/ui/debug/DebugPreferenceManager.kt", + "com/bitchat/android/ui/NotificationTextUtils.kt", + "com/bitchat/android/util/AppConstants.kt", + "com/bitchat/android/util/ByteArrayExtensions.kt", + "com/bitchat/android/util/ByteArrayWrapper.kt", + "com/bitchat/android/util/BinaryEncodingUtils.kt", +) +val sharedSourceExcludes = listOf( + "com/bitchat/android/model/FileSharingManager.kt", + // Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service + // (MeshCore-style) in M2 instead of reusing these. + "com/bitchat/android/mesh/BluetoothMeshService.kt", + "com/bitchat/android/mesh/UnifiedMeshService.kt", + // Phone permission policy additionally requires location (legacy BLE); the watch app + // declares Bluetooth permissions only, so it ships its own same-FQN variant in + // wear/src/main (Bluetooth-only check). + "com/bitchat/android/mesh/BluetoothPermissionManager.kt", +) + +val syncSharedAppSources = tasks.register("syncSharedAppSources") { + from("../app/src/main/java") { + include(sharedSourceIncludes) + exclude(sharedSourceExcludes) + } + into(layout.buildDirectory.dir("sharedSrc")) +} + +// The app's own unit tests for the shared packages also run in the wear module, so shared +// behavior is continuously verified on both targets. Includes the app's JVM shims for +// android.util.Log/Base64 (app/src/test/kotlin/android) that the shared code needs on the JVM. +val syncSharedAppTests = tasks.register("syncSharedAppTests") { + from("../app/src/test/java") { + include( + "com/bitchat/android/protocol/**", + "com/bitchat/android/crypto/**", + "com/bitchat/android/mesh/**", + ) + } + from("../app/src/test/kotlin") { + include( + "android/**", + "com/bitchat/android/mesh/**", + "com/bitchat/FileTransferTest.kt", + ) + } + into(layout.buildDirectory.dir("sharedTestSrc")) +} + +android { + sourceSets { + getByName("main") { + java.srcDir("build/sharedSrc") + kotlin.srcDir("build/sharedSrc") + } + getByName("test") { + java.srcDir("build/sharedTestSrc") + kotlin.srcDir("build/sharedTestSrc") + } + } +} + +tasks.withType().configureEach { + dependsOn(syncSharedAppSources) +} +tasks.matching { it.name.contains("UnitTest", ignoreCase = true) }.configureEach { + dependsOn(syncSharedAppTests) +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + + // Wear Compose + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.compose.material3) + implementation(libs.androidx.wear.tooling.preview) + implementation(libs.androidx.compose.material.icons.extended) + + // Lifecycle + implementation(libs.bundles.lifecycle) + implementation(libs.androidx.lifecycle.process) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Cryptography (shared Noise/encryption stack) + implementation(libs.bouncycastle.bcprov) + + // JSON (BitchatMessage model) + implementation(libs.gson) + + // Security preferences (Noise identity persistence) + implementation(libs.androidx.security.crypto) + + // Testing + testImplementation(libs.bundles.testing) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro new file mode 100644 index 00000000..001b0a09 --- /dev/null +++ b/wear/proguard-rules.pro @@ -0,0 +1,13 @@ +# Gson reflection targets in the shared bitchat sources (persisted state payloads). +-keep class com.bitchat.android.favorites.** { *; } +-keep class com.bitchat.android.services.SeenMessageStore$* { *; } +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# Kotlin metadata needed by reflection-based serialization. +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Tink references JSR-305 annotations not present on Android. +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.concurrent.GuardedBy diff --git a/wear/src/debug/AndroidManifest.xml b/wear/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..20c0f07f --- /dev/null +++ b/wear/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt new file mode 100644 index 00000000..c43ef258 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -0,0 +1,389 @@ +package com.bitchat.watch.testhook + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.service.WearMeshForegroundService +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Headless engine behind [WearTestHookReceiver]. Drives [WearMeshService] and observes + * [AppStateStore] flows. Command set mirrors the phone's TestHookDriver (minus file transfer, + * which is deferred on the watch). + */ +object WearTestHookDriver { + + private const val TAG = WearTestHookReceiver.TAG + + private const val DEFAULT_SCAN_TIMEOUT_MS = 30_000L + private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L + private const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000L + private const val DEFAULT_RECV_TIMEOUT_MS = 60_000L + + suspend fun execute(context: Context, cmd: String, intent: Intent): JSONObject { + Log.d(TAG, "execute cmd=$cmd") + val result = when (cmd) { + "ping" -> ok(cmd).put("pong", true).put("package", context.packageName) + "start" -> start(context) + "stop" -> stop(context) + "whoami" -> whoami(context) + "set_nickname" -> setNickname(context, intent.requiredString("name")) + "scan" -> scan(context, intent) + "peers" -> peers(context) + "connect" -> connect(intent.requiredString("peer"), intent) + "handshake" -> handshake(context, intent.requiredString("peer"), intent) + "session" -> session(context, intent.requiredString("peer")) + "announce" -> announce(context) + "broadcast_msg" -> broadcastMsg(context, intent.requiredString("content")) + "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) + "dm_recv" -> dmRecv(context, intent) + "msg_recv" -> msgRecv(context, intent) + "raw_send" -> rawSend(context, intent) + "file_recv" -> fileRecv(context, intent) + "state" -> state(context) + "clear_results" -> clearResults(context) + else -> err(cmd, "unknown command: $cmd") + } + return result.put("cmd", cmd) + } + + // MARK: - Lifecycle + + private fun start(context: Context): JSONObject { + val mesh = mesh(context) + try { + context.startForegroundService(Intent(context, WearMeshForegroundService::class.java)) + } catch (e: Exception) { + // Background FGS starts are restricted (API 31+); mesh_lab launches the app first, + // but fall back to a service-less mesh start so the command still works. + Log.w(TAG, "foreground service start failed, starting mesh directly: ${e.message}") + } + mesh.startServices() + return ok("start").put("peer_id", mesh.myPeerID) + } + + private fun stop(context: Context): JSONObject { + try { + WearMeshService.peek()?.stopServices() + } catch (e: Exception) { + Log.w(TAG, "stopServices failed: ${e.message}") + } + context.stopService(Intent(context, WearMeshForegroundService::class.java)) + return ok("stop") + } + + // MARK: - Identity + + private fun whoami(context: Context): JSONObject { + val mesh = mesh(context) + return ok("whoami") + .put("peer_id", mesh.myPeerID) + .put("identity_fingerprint", mesh.getIdentityFingerprint()) + .put("noise_public_key", mesh.getStaticNoisePublicKey()?.toHex()) + .put("nickname", mesh.nickname) + } + + private fun setNickname(context: Context, name: String): JSONObject { + mesh(context).setNickname(name) + AppStateStore.setNickname(name) + return ok("set_nickname").put("nickname", name) + } + + // MARK: - Discovery / connection + + private suspend fun scan(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_SCAN_TIMEOUT_MS) + val minPeers = intent.getIntExtra("min_peers", 1) + val mesh = mesh(context) + val found = withTimeoutOrNull(timeoutMs) { + AppStateStore.peers.first { it.size >= minPeers } + } + val peerIds = found ?: AppStateStore.peers.value + return ok("scan") + .put("reached_min_peers", found != null) + .put("peers", peerInfosJson(mesh, peerIds)) + } + + private fun peers(context: Context): JSONObject { + val mesh = mesh(context) + return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value)) + } + + private suspend fun connect(peerID: String, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS) + val mesh = WearMeshService.peek() ?: return err("connect", "mesh service not running") + // The address↔peer mapping is learned from direct-link announces and can lag + // peer-list discovery (especially right after a restart); poll while announcing. + val deadline = System.currentTimeMillis() + timeoutMs / 2 + var address: String? = mesh.getDeviceAddressForPeer(peerID) + while (address == null && System.currentTimeMillis() < deadline) { + mesh.sendBroadcastAnnounce() + delay(1_000) + address = mesh.getDeviceAddressForPeer(peerID) + } + if (address == null) { + return err("connect", "no device address known for peer $peerID (scan first)") + } + val accepted = mesh.connectToPeer(peerID) + if (!accepted) return err("connect", "connectToAddress($address) rejected") + val direct = withTimeoutOrNull(timeoutMs) { + AppStateStore.directPeers.first { it.contains(peerID) } + } + return ok("connect") + .put("peer", peerID) + .put("address", address) + .put("direct", direct != null) + } + + // MARK: - Noise + + private suspend fun handshake(context: Context, peerID: String, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_HANDSHAKE_TIMEOUT_MS) + val mesh = mesh(context) + val deadline = System.currentTimeMillis() + timeoutMs + if (!mesh.hasEstablishedSession(peerID)) { + mesh.initiateNoiseHandshake(peerID) + } + var lastState: NoiseSession.NoiseSessionState = NoiseSession.NoiseSessionState.Uninitialized + while (System.currentTimeMillis() < deadline) { + lastState = mesh.getSessionState(peerID) + when (lastState) { + is NoiseSession.NoiseSessionState.Established -> { + return ok("handshake") + .put("peer", peerID) + .put("state", lastState.toString()) + .put("fingerprint", mesh.getPeerFingerprint(peerID)) + } + is NoiseSession.NoiseSessionState.Failed -> { + return err("handshake", "session failed: $lastState").put("peer", peerID) + } + else -> delay(100) + } + } + return err("handshake", "timeout after ${timeoutMs}ms (last state: $lastState)").put("peer", peerID) + } + + private fun session(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + return ok("session") + .put("peer", peerID) + .put("state", mesh.getSessionState(peerID).toString()) + .put("established", mesh.hasEstablishedSession(peerID)) + .put("fingerprint", mesh.getPeerFingerprint(peerID)) + } + + // MARK: - Messaging + + private fun announce(context: Context): JSONObject { + mesh(context).sendBroadcastAnnounce() + return ok("announce") + } + + private fun broadcastMsg(context: Context, content: String): JSONObject { + mesh(context).sendChannelMessage(content, emptyList(), null) + return ok("broadcast_msg").put("content", content) + } + + private fun dmSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject { + val mesh = mesh(context) + val nickname = mesh.getPeerNicknames()[peerID] ?: peerID + val id = msgID ?: "testhook-${System.currentTimeMillis()}" + mesh.sendPrivateMessageWithId(content, peerID, nickname, id) + return ok("dm_send").put("peer", peerID).put("msg_id", id) + } + + private suspend fun dmRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) + val fromPeer = intent.getStringExtra("peer") + val contains = intent.getStringExtra("contains") + val startTime = System.currentTimeMillis() + val mesh = mesh(context) + val match = withTimeoutOrNull(timeoutMs) { + AppStateStore.privateMessages.first { conversations -> + conversations.values.flatten().any { msg -> + msg.timestamp.time >= startTime && + msg.senderPeerID != mesh.myPeerID && + (fromPeer == null || msg.senderPeerID == fromPeer) && + (contains == null || msg.content.contains(contains)) + } + } + } ?: return err("dm_recv", "timeout after ${timeoutMs}ms") + val msg = match.values.flatten().first { msg -> + msg.timestamp.time >= startTime && + msg.senderPeerID != mesh.myPeerID && + (fromPeer == null || msg.senderPeerID == fromPeer) && + (contains == null || msg.content.contains(contains)) + } + return ok("dm_recv") + .put("from", msg.senderPeerID) + .put("sender", msg.sender) + .put("content", msg.content) + .put("msg_id", msg.id) + } + + private suspend fun msgRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) + val contains = intent.getStringExtra("contains") + val startTime = System.currentTimeMillis() + val mesh = mesh(context) + val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg -> + msg.timestamp.time >= startTime && + msg.senderPeerID != mesh.myPeerID && + (contains == null || msg.content.contains(contains)) + } + val found = withTimeoutOrNull(timeoutMs) { + AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches) + } ?: return err("msg_recv", "timeout after ${timeoutMs}ms") + return ok("msg_recv") + .put("from", found.senderPeerID) + .put("sender", found.sender) + .put("content", found.content) + .put("msg_id", found.id) + } + + // MARK: - Raw packet injection + + private fun rawSend(context: Context, intent: Intent): JSONObject { + val payloadHex = intent.requiredString("payload_hex") + val typeStr = intent.requiredString("type") + val peerID = intent.getStringExtra("peer") + val ttl = intent.getIntExtra("ttl", 7) + val type = typeStr.toUIntOrNull(16)?.toUByte() + ?: return err("raw_send", "invalid type hex: $typeStr") + val payload = hexToBytes(payloadHex) + ?: return err("raw_send", "invalid payload_hex") + val mesh = mesh(context) + val packet = BitchatPacket( + type = type, + ttl = ttl.toUByte(), + senderID = mesh.myPeerID, + payload = payload + ) + if (peerID != null) { + TransportBridgeService.sendToPeerFromLocal(peerID, packet) + } else { + TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) + } + return ok("raw_send") + .put("type", typeStr) + .put("payload_bytes", payload.size) + .put("peer", peerID) + } + + // MARK: - File transfer (receive only; the watch does not send files via test hook) + + private suspend fun fileRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L) + val nameContains = intent.getStringExtra("name_contains") + val startTime = System.currentTimeMillis() + val dirs = listOf( + File(context.cacheDir, "files/incoming"), + File(context.cacheDir, "images/incoming") + ) + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val candidate = dirs + .flatMap { it.listFiles()?.toList() ?: emptyList() } + .filter { it.lastModified() >= startTime - 5_000 } + .filter { nameContains == null || it.name.contains(nameContains) } + .maxByOrNull { it.lastModified() } + if (candidate != null) { + val size1 = candidate.length() + delay(500) + if (candidate.length() == size1 && size1 > 0) { + return ok("file_recv") + .put("path", candidate.absolutePath) + .put("name", candidate.name) + .put("bytes", size1) + .put( + "sha256", + java.security.MessageDigest.getInstance("SHA-256") + .digest(candidate.readBytes()).toHex() + ) + } + } + delay(250) + } + return err("file_recv", "timeout after ${timeoutMs}ms") + } + + // MARK: - State + + private fun state(context: Context): JSONObject { + val mesh = mesh(context) + val peersJson = peerInfosJson(mesh, AppStateStore.peers.value) + val sessions = JSONObject() + AppStateStore.peers.value.forEach { peerID -> + sessions.put(peerID, mesh.getSessionState(peerID).toString()) + } + return ok("state") + .put("peer_id", mesh.myPeerID) + .put("nickname", mesh.nickname) + .put("peers", peersJson) + .put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList())) + .put("sessions", sessions) + .put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>)) + .put("debug_status", mesh.getDebugStatus()) + } + + private fun clearResults(context: Context): JSONObject { + val dir = File(context.cacheDir, "testhook/results") + val count = dir.listFiles()?.count { it.delete() } ?: 0 + return ok("clear_results").put("deleted", count) + } + + // MARK: - Helpers + + private fun mesh(context: Context): WearMeshService = WearMeshService.getOrCreate(context) + + private fun peerInfosJson(mesh: WearMeshService, peerIds: List): JSONArray { + val nicknames = mesh.getPeerNicknames() + val rssi = mesh.getPeerRSSI() + val arr = JSONArray() + peerIds.forEach { id -> + val info = mesh.getPeerInfo(id) + arr.put(JSONObject() + .put("id", id) + .put("nickname", nicknames[id] ?: info?.nickname) + .put("rssi", rssi[id]) + .put("direct", AppStateStore.directPeers.value.contains(id)) + .put("connected", info?.isConnected) + .put("last_seen", info?.lastSeen) + .put("session", mesh.getSessionState(id).toString()) + .put("fingerprint", mesh.getPeerFingerprint(id))) + } + return arr + } + + private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd) + private fun err(cmd: String, message: String) = + JSONObject().put("status", "error").put("cmd", cmd).put("error", message) + + private fun Intent.requiredString(name: String): String = + getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name") + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun hexToBytes(hex: String): ByteArray? { + val clean = hex.replace(" ", "") + if (clean.length % 2 != 0) return null + return try { + ByteArray(clean.length / 2) { i -> + clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } catch (e: Exception) { + null + } + } +} diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt new file mode 100644 index 00000000..080185d0 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt @@ -0,0 +1,61 @@ +package com.bitchat.watch.testhook + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import java.io.File + +/** + * ADB-drivable test hook for the watch app (debug builds only). Mirrors the phone's protocol: + * + * adb shell am broadcast -a com.bitchat.watch.TEST_HOOK \ + * --es cmd --es id [command extras...] + * + * Result is written to cache/testhook/results/.json (readable via run-as com.bitchat.watch) + * and logged under tag TestHook. + */ +class WearTestHookReceiver : BroadcastReceiver() { + + companion object { + const val TAG = "TestHook" + const val ACTION = "com.bitchat.watch.TEST_HOOK" + private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION) return + val cmd = intent.getStringExtra("cmd") ?: "ping" + val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}" + val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS) + + Log.i(TAG, "CMD id=$id cmd=$cmd") + + val pendingResult = goAsync() + Thread { + val result = try { + runBlocking { + withTimeout(overallTimeout) { + WearTestHookDriver.execute(context.applicationContext, cmd, intent) + } + } + } catch (e: Exception) { + JSONObject() + .put("status", "error") + .put("cmd", cmd) + .put("error", "${e.javaClass.simpleName}: ${e.message}") + } + try { + val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() } + File(dir, "$id.json").writeText(result.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to write result file for $id: ${e.message}") + } + Log.i(TAG, "RESULT id=$id $result") + }.start() + pendingResult.finish() + } +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..531286cf --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt new file mode 100644 index 00000000..a6e8a164 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt @@ -0,0 +1,45 @@ +package com.bitchat.android.mesh + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat + +/** + * Wear variant of the phone's BluetoothPermissionManager. + * + * The phone version additionally requires ACCESS_FINE/COARSE_LOCATION (legacy BLE scanning + * behavior on older phones). The watch app deliberately declares no location permissions — + * on Wear OS, BLUETOOTH_SCAN with the `neverForLocation` flag is sufficient — so only the + * Bluetooth runtime permissions are checked here. + * + * Same fully-qualified name as the phone class, which is excluded from the wear shared-source + * sync (see wear/build.gradle.kts), so there is exactly one definition in this compilation. + */ +class BluetoothPermissionManager(private val context: Context) { + + fun hasBluetoothPermissions(): Boolean { + val permissions = mutableListOf() + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + permissions.addAll( + listOf( + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN + ) + ) + } else { + permissions.addAll( + listOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN + ) + ) + } + + return permissions.all { + ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt new file mode 100644 index 00000000..bd640a84 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -0,0 +1,17 @@ +package com.bitchat.android.service + +/** + * Wear shim for the phone's MeshServiceHolder. + * + * The shared `DebugSettingsManager` (compiled from app sources) references this holder only to + * toggle BLE transport from the phone's debug UI, which does not exist on the watch. The real + * holder is typed against `BluetoothMeshService`, which the watch deliberately does not include. + */ +object MeshServiceHolder { + + interface BleToggle { + fun setBleTransportEnabled(enabled: Boolean) + } + + val meshService: BleToggle? = null +} diff --git a/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt b/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt new file mode 100644 index 00000000..2b491274 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt @@ -0,0 +1,11 @@ +package com.bitchat.android.wifiaware + +/** + * Wear shim for the phone's WifiAwareController. + * + * Referenced only by the shared `DebugSettingsManager` debug-UI toggle. Wi-Fi Aware is out of + * scope for the watch (Bluetooth mesh only), so this is a no-op. + */ +object WifiAwareController { + fun setEnabled(value: Boolean) = Unit +} diff --git a/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt new file mode 100644 index 00000000..f480cfdd --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt @@ -0,0 +1,13 @@ +package com.bitchat.watch + +import android.app.Application +import com.bitchat.android.mesh.PowerManager +import com.bitchat.watch.notification.WearNotificationCoordinator + +class BitchatWatchApplication : Application() { + override fun onCreate() { + super.onCreate() + PowerManager.getInstance(applicationContext) + WearNotificationCoordinator.getInstance(applicationContext) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt new file mode 100644 index 00000000..7770a6f4 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -0,0 +1,357 @@ +package com.bitchat.watch + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TextButton +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.service.WearMeshForegroundService +import com.bitchat.watch.ui.ChatScreen +import com.bitchat.watch.ui.DmScreen +import com.bitchat.watch.ui.NicknameSetupScreen +import com.bitchat.watch.ui.PeopleScreen +import com.bitchat.watch.ui.WearChatState +import com.bitchat.watch.ui.sendPrivateMessage +import com.bitchat.watch.ui.sendPublicMessage +import com.bitchat.watch.ui.theme.BitchatWearTheme + +sealed interface WearScreen { + data object Chat : WearScreen + data object People : WearScreen + data object Nickname : WearScreen + data class Dm(val peerID: String) : WearScreen + data class TextInput(val peerID: String?) : WearScreen +} + +class MainActivity : ComponentActivity() { + + private var hasPermissions by mutableStateOf(false) + private var bluetoothEnabled by mutableStateOf(false) + private var nicknameChosen by mutableStateOf(false) + private var notificationsGranted by mutableStateOf(false) + private var notificationPromptDismissed by mutableStateOf(false) + private var pendingDmPeer by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) + .getBoolean("nickname_chosen", false) + pendingDmPeer = privateMessagePeerFromIntent(intent) + refreshState() + setContent { + BitchatWearTheme { + when { + !hasPermissions -> PermissionRequestScreen(onGranted = { refreshState() }) + !bluetoothEnabled -> BluetoothEnableScreen(onEnabled = { refreshState() }) + !nicknameChosen -> NicknameSetupScreen( + initialNickname = WearMeshService.getOrCreate(applicationContext).nickname + ) { name -> + WearMeshService.getOrCreate(applicationContext).setNickname(name) + getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) + .edit().putBoolean("nickname_chosen", true).apply() + nicknameChosen = true + } + !notificationsGranted && !notificationPromptDismissed -> + NotificationPermissionScreen( + onResult = { granted -> + notificationPromptDismissed = !granted + refreshState() + }, + onSkip = { notificationPromptDismissed = true } + ) + else -> WearNavHost( + openDmPeer = pendingDmPeer, + onOpenDmHandled = { pendingDmPeer = null } + ) + } + } + } + } + + override fun onResume() { + super.onResume() + WearChatState.setAppInForeground(true) + WearChatState.openDmPeer?.let { peerID -> + WearChatState.openDm(peerID) + WearNotificationCoordinator.getInstance(applicationContext).clearConversation(peerID) + } + refreshState() + } + + override fun onPause() { + WearChatState.setAppInForeground(false) + super.onPause() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + privateMessagePeerFromIntent(intent)?.let { pendingDmPeer = it } + } + + private fun refreshState() { + hasPermissions = requiredPermissions().all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + notificationsGranted = notificationPermissionGranted() + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + bluetoothEnabled = adapter?.isEnabled == true + if (hasPermissions && bluetoothEnabled) { + startMeshService() + } + } + + private fun startMeshService() { + WearMeshService.getOrCreate(applicationContext) + startForegroundService(Intent(this, WearMeshForegroundService::class.java)) + } + + private fun privateMessagePeerFromIntent(intent: Intent?): String? { + if (intent?.getBooleanExtra(WearNotificationCoordinator.EXTRA_OPEN_DM, false) != true) { + return null + } + return intent.getStringExtra(WearNotificationCoordinator.EXTRA_PEER_ID) + ?.takeIf { it.isNotBlank() } + } + + private fun notificationPermissionGranted(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + companion object { + fun requiredPermissions(): List = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add(Manifest.permission.BLUETOOTH_SCAN) + add(Manifest.permission.BLUETOOTH_CONNECT) + add(Manifest.permission.BLUETOOTH_ADVERTISE) + } + } + } +} + +@Composable +fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { + var screen by remember { mutableStateOf(WearScreen.Chat) } + val backStack = remember { mutableStateListOf() } + + fun navigate(to: WearScreen) { + backStack.add(screen) + screen = to + } + + fun goBack(): Boolean { + val previous = backStack.removeLastOrNull() + return if (previous != null) { + screen = previous + true + } else false + } + + BackHandler(enabled = backStack.isNotEmpty()) { goBack() } + + LaunchedEffect(openDmPeer) { + openDmPeer?.let { peerID -> + backStack.clear() + screen = WearScreen.Dm(peerID) + onOpenDmHandled() + } + } + + AnimatedContent( + targetState = screen, + transitionSpec = { + fadeIn(tween(com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS)) togetherWith + fadeOut(tween(com.bitchat.watch.ui.theme.BitchatMotion.QUICK_MS)) + }, + label = "screenTransition" + ) { current -> + when (current) { + is WearScreen.Chat -> ChatScreen( + onOpenPeople = { navigate(WearScreen.People) }, + onOpenTextInput = { navigate(WearScreen.TextInput(null)) } + ) + is WearScreen.People -> PeopleScreen( + onOpenDm = { navigate(WearScreen.Dm(it)) }, + onEditNickname = { navigate(WearScreen.Nickname) } + ) + is WearScreen.Nickname -> { + val mesh = WearMeshService.peek() + NicknameSetupScreen( + initialNickname = mesh?.nickname ?: "", + title = "You", + subtitle = "How nearby peers see you", + confirmLabel = "Save", + onConfirm = { name -> + mesh?.setNickname(name) + goBack() + } + ) + } + is WearScreen.Dm -> DmScreen( + peerID = current.peerID, + onOpenTextInput = { navigate(WearScreen.TextInput(current.peerID)) } + ) + is WearScreen.TextInput -> { + val mesh = WearMeshService.peek() + val sendScope = androidx.compose.runtime.rememberCoroutineScope() + com.bitchat.watch.ui.TextInputScreen( + onSend = { text -> + mesh?.let { m -> + if (current.peerID == null) { + sendPublicMessage(m, text) + } else { + val nick = m.getPeerNickname(current.peerID) ?: current.peerID + sendPrivateMessage(m, current.peerID, nick, text, sendScope) + } + } + goBack() + } + ) + } + } + } +} + +@Composable +fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> onResult(granted) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Message alerts", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Alerts for encrypted direct messages", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp, bottom = 10.dp) + ) + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + onResult(true) + } + } + ) { + Text("Enable") + } + TextButton(onClick = onSkip) { + Text("Not now") + } + } +} + +@Composable +fun PermissionRequestScreen(onGranted: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { onGranted() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "bitchat", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Needs Bluetooth to mesh with nearby devices", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp, bottom = 12.dp) + ) + Button(onClick = { + launcher.launch(MainActivity.requiredPermissions().toTypedArray()) + }) { + Text("Grant access") + } + } +} + +@Composable +fun BluetoothEnableScreen(onEnabled: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { onEnabled() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Bluetooth is off", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Button( + onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }, + modifier = Modifier.padding(top = 10.dp) + ) { + Text("Turn on") + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt new file mode 100644 index 00000000..71d707f1 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -0,0 +1,401 @@ +package com.bitchat.watch.mesh + +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.util.Log +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.mesh.BluetoothConnectionManager +import com.bitchat.android.mesh.BluetoothConnectionManagerDelegate +import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy +import com.bitchat.android.mesh.MeshCore +import com.bitchat.android.mesh.MeshTransport +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Watch mesh service: composes the shared BLE transport (BluetoothConnectionManager) with the + * shared mesh coordinator (MeshCore), mirroring how the phone's Wi-Fi Aware service is built. + * Bluetooth mesh only — no internet, no other transports. + */ +class WearMeshService private constructor(private val context: Context) { + + companion object { + private const val TAG = "WearMeshService" + private val MAX_TTL: UByte = AppConstants.MESSAGE_TTL_HOPS + private val PEER_DISCONNECT_GRACE_MS: Long = AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS + + @Volatile + private var instance: WearMeshService? = null + + fun getOrCreate(context: Context): WearMeshService { + return instance ?: synchronized(this) { + instance ?: WearMeshService(context.applicationContext).also { instance = it } + } + } + + fun peek(): WearMeshService? = instance + } + + val encryptionService = EncryptionService(context) + val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val bleTransport = BleTransport() + private val meshCore: MeshCore + private var connectionManager: BluetoothConnectionManager + + @Volatile + var nickname: String = loadNickname() + private set + + @Volatile + private var isActive = false + + /** UI hook fired for every incoming private message (after storing). */ + var onPrivateMessage: ((com.bitchat.android.model.BitchatMessage) -> Unit)? = null + + init { + meshCore = MeshCore( + context = context.applicationContext, + scope = serviceScope, + transport = bleTransport, + encryptionService = encryptionService, + myPeerID = myPeerID, + maxTtl = MAX_TTL, + sharedGossipManager = null, + gossipConfigProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + }, + hooks = MeshCore.Hooks( + onMessageReceived = { message -> handleMessageReceived(message) }, + onAnnounceProcessed = { routed, _ -> + // Mirror the phone's BluetoothMeshService: learn the direct BLE + // address↔peerID mapping from direct-link announcements. + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { obs -> + val observed = connectionManager.observePeerIfCurrent( + obs.relayAddress, + obs.ingressLinkID, + obs.peerID + ) + if (observed) { + meshCore.setDirectConnection(obs.peerID, true) + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(obs.peerID, 1_000) + } catch (_: Exception) { } + } + } + routed.peerID?.let { pid -> + maybeAutoHandshake(pid) + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) + } catch (_: Exception) { } + } + }, + announcementNicknameProvider = { nickname }, + leavePayloadProvider = { nickname.toByteArray(Charsets.UTF_8) } + ) + ) + connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) + bleTransport.connectionManager = connectionManager + wireBluetoothDelegate() + } + + private inner class BleTransport : MeshTransport { + lateinit var connectionManager: BluetoothConnectionManager + + override val id: String = "BLE" + + override fun broadcastPacket(routed: RoutedPacket): Boolean = + connectionManager.broadcastPacket(routed) + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean = + connectionManager.sendPacketToPeer(peerID, packet) + + override fun sendPacketToLink( + relayAddress: String, + ingressLinkID: String, + packet: BitchatPacket + ): Boolean = connectionManager.sendPacketToLink(relayAddress, ingressLinkID, packet) + + override fun cancelTransfer(transferId: String): Boolean = + connectionManager.cancelTransfer(transferId) + + override fun getDeviceAddressForPeer(peerID: String): String? = + connectionManager.addressPeerMap.entries.firstOrNull { it.value == peerID }?.key + + override fun getDeviceAddressToPeerMapping(): Map = + connectionManager.addressPeerMap.toMap() + + override fun getTransportDebugInfo(): String = connectionManager.getDebugInfo() + } + + private fun wireBluetoothDelegate() { + connectionManager.delegate = object : BluetoothConnectionManagerDelegate { + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) { + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( + packet = packet, + fromPeerID = peerID, + fromNickname = null, + fromDeviceAddress = device?.address, + myPeerID = myPeerID + ) + } catch (_: Exception) { } + meshCore.processIncoming(packet, peerID, device?.address, ingressLinkID) + } + + override fun onDeviceConnected(device: BluetoothDevice) { + Log.i(TAG, "Device connected: ${device.address}") + serviceScope.launch { + delay(200) + meshCore.sendBroadcastAnnounce() + } + } + + override fun onDeviceDisconnected( + device: BluetoothDevice, + linkID: String?, + peerID: String? + ) { + Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)") + try { meshCore.refreshPeerList() } catch (_: Exception) { } + if (peerID != null) { + meshCore.setDirectConnection(peerID, false) + val deviceAddress = device.address + serviceScope.launch { + delay(PEER_DISCONNECT_GRACE_MS) + try { + val linkBack = + connectionManager.addressPeerMap.containsKey(deviceAddress) || + connectionManager.addressPeerMap.containsValue(peerID) + if (!linkBack) { + Log.i(TAG, "Peer $peerID did not return after disconnect; removing") + meshCore.removePeer(peerID) + } + } catch (e: Exception) { + Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}") + } + } + } + } + + override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { + connectionManager.addressPeerMap[deviceAddress]?.let { peerID -> + meshCore.updatePeerRSSI(peerID, rssi) + } + } + } + } + + /** + * Proactively establish a Noise session with peers we have no session for (throttled to + * one attempt per peer per 60 s). Peers may hold a stale session after we restart — the + * protocol has no decrypt-failure kick path, so our fresh handshake replaces it and + * restores encrypted DM/file delivery. + */ + private val handshakeAttempts = java.util.concurrent.ConcurrentHashMap() + + private fun maybeAutoHandshake(peerID: String) { + if (peerID == myPeerID || hasEstablishedSession(peerID)) return + val now = System.currentTimeMillis() + val last = handshakeAttempts[peerID] ?: 0L + if (now - last < 60_000) return + handshakeAttempts[peerID] = now + serviceScope.launch { + delay(1_500) + if (!hasEstablishedSession(peerID)) { + try { + Log.d(TAG, "Auto-initiating Noise handshake with ${peerID.take(8)}") + initiateNoiseHandshake(peerID) + } catch (_: Exception) { } + } + } + } + + private fun handleMessageReceived(message: com.bitchat.android.model.BitchatMessage) { + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: return + AppStateStore.addPrivateMessage(peer, message) + try { onPrivateMessage?.invoke(message) } catch (_: Exception) { } + } + message.channel != null -> AppStateStore.addChannelMessage(message.channel!!, message) + else -> AppStateStore.addPublicMessage(message) + } + } catch (_: Exception) { } + } + + fun startServices() { + if (isActive) { + Log.w(TAG, "Mesh already active, ignoring duplicate start") + return + } + if (!connectionManager.isReusable()) { + // A previous stopServices() cancelled the manager's coroutine scope; the shared + // API marks such managers single-use, so build a fresh one instead of starting + // a zombie mesh that reports active while scanning nothing. + Log.i(TAG, "Recreating BluetoothConnectionManager after terminal stop") + connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) + bleTransport.connectionManager = connectionManager + wireBluetoothDelegate() + } + val started = connectionManager.startServices() + if (started) { + isActive = true + meshCore.startCore() + serviceScope.launch { + delay(500) + meshCore.sendBroadcastAnnounce() + } + Log.i(TAG, "Mesh services started (peerID: $myPeerID)") + } else { + Log.e(TAG, "Failed to start Bluetooth services (permissions? BT off?)") + } + } + + fun stopServices() { + if (!isActive) return + isActive = false + meshCore.stopCore() + connectionManager.stopServices() + Log.i(TAG, "Mesh services stopped") + } + + fun isRunning(): Boolean = isActive + + fun setNickname(name: String) { + val trimmed = name.trim().take(32) + if (trimmed.isEmpty() || trimmed == nickname) return + nickname = trimmed + saveNickname(trimmed) + if (isActive) { + serviceScope.launch { meshCore.sendBroadcastAnnounce() } + } + } + + fun sendMessage(content: String, mentions: List = emptyList()) { + meshCore.sendMessage(content, mentions, null) + } + + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname) + } + + fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID) + + fun hasEstablishedSession(peerID: String): Boolean = meshCore.hasEstablishedSession(peerID) + + fun getSessionState(peerID: String) = meshCore.getSessionState(peerID) + + fun getPeerInfo(peerID: String) = meshCore.getPeerInfo(peerID) + + fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + + fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey() + + fun sendBroadcastAnnounce() = meshCore.sendBroadcastAnnounce() + + fun sendChannelMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + meshCore.sendMessage(content, mentions, channel) + } + + fun sendPrivateMessageWithId( + content: String, + recipientPeerID: String, + recipientNickname: String, + messageID: String? + ) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + + fun getDeviceAddressForPeer(peerID: String): String? = meshCore.getDeviceAddressForPeer(peerID) + + fun getDeviceAddressToPeerMapping(): Map = meshCore.getDeviceAddressToPeerMapping() + + fun connectToPeer(peerID: String): Boolean { + val address = getDeviceAddressForPeer(peerID) ?: return false + return connectionManager.connectToAddress(address) + } + + fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { + meshCore.sendFileBroadcast(file) + } + + /** + * Noise-encrypted private file transfer with session/prep retry (mirrors the phone's + * dispatchFileSend): ensures an established session, then retries transient + * preparation states (AwaitingPeerState/NeedsHandshake) before giving up. + */ + fun sendFilePrivateEncrypted(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { + serviceScope.launch { + val sessionDeadline = System.currentTimeMillis() + 15_000 + while (!hasEstablishedSession(recipientPeerID) && System.currentTimeMillis() < sessionDeadline) { + try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { } + delay(500) + } + val transferId = com.bitchat.android.mesh.MeshPacketUtils.sha256Hex( + file.encode() ?: return@launch + ) + val prepDeadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < prepDeadline) { + when (val prep = meshCore.prepareFilePrivate(recipientPeerID, file, transferId, allowLegacyFallback = false)) { + is com.bitchat.android.mesh.PrivateMediaPreparation.Ready -> { + prep.transfer.commit() + return@launch + } + com.bitchat.android.mesh.PrivateMediaPreparation.AwaitingPeerState, + com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake -> { + if (prep == com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake) { + try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { } + } + delay(500) + } + else -> { + Log.w(TAG, "private voice note preparation failed: $prep") + return@launch + } + } + } + Log.w(TAG, "private voice note preparation timed out") + } + } + + fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID) + + fun getPeerNicknames(): Map = meshCore.getPeerNicknames() + + fun getPeerRSSI(): Map = meshCore.getPeerRSSI() + + fun getPeerNickname(peerID: String): String? = meshCore.getPeerNickname(peerID) + + fun getDebugStatus(): String = meshCore.getDebugStatus( + transportInfo = connectionManager.getDebugInfo(), + deviceMap = connectionManager.addressPeerMap.toMap(), + title = "Wear BLE Mesh Debug Status" + ) + + private fun prefs() = context.getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) + + private fun loadNickname(): String = + prefs().getString("nickname", null) ?: "watch-${myPeerID.take(4)}" + + private fun saveNickname(name: String) { + prefs().edit().putString("nickname", name).apply() + } +} diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt new file mode 100644 index 00000000..ab69375c --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt @@ -0,0 +1,230 @@ +package com.bitchat.watch.notification + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.content.ContextCompat +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.NotificationTextUtils +import com.bitchat.watch.MainActivity +import com.bitchat.watch.R +import com.bitchat.watch.ui.WearChatState +import java.util.concurrent.ConcurrentHashMap + +/** + * Process-wide owner for local Wear notifications. + * + * Mesh delivery must not depend on an Activity being alive, so the foreground service invokes this + * coordinator directly. UI state is consulted only to suppress an alert for the exact DM that is + * currently visible while the app is resumed. + */ +class WearNotificationCoordinator private constructor(context: Context) { + + companion object { + const val EXTRA_OPEN_DM = "com.bitchat.watch.extra.OPEN_DM" + const val EXTRA_PEER_ID = "com.bitchat.watch.extra.PEER_ID" + + private const val MESSAGE_CHANNEL_ID = "bitchat_watch_messages" + private const val GROUP_KEY_DM = "bitchat_watch_dm_group" + private const val SUMMARY_NOTIFICATION_ID = 2 + private const val CONVERSATION_NOTIFICATION_ID_BASE = 10_000 + + @Volatile + private var instance: WearNotificationCoordinator? = null + + fun getInstance(context: Context): WearNotificationCoordinator { + return instance ?: synchronized(this) { + instance ?: WearNotificationCoordinator(context.applicationContext).also { + instance = it + } + } + } + } + + private data class PendingMessage( + val senderNickname: String, + val preview: String, + val timestamp: Long + ) + + private val appContext = context.applicationContext + private val notificationManager = NotificationManagerCompat.from(appContext) + private val pendingMessages = ConcurrentHashMap>() + + init { + createMessageChannel() + } + + @Synchronized + fun onPrivateMessage( + message: BitchatMessage, + senderPeerID: String, + senderNickname: String + ) { + val shouldNotify = WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = senderPeerID, + senderIsSystem = message.sender == "system", + appInForeground = WearChatState.appInForeground, + openDmPeer = WearChatState.openDmPeer + ) + if (!shouldNotify || !canPostNotifications()) return + + pendingMessages.getOrPut(senderPeerID) { mutableListOf() }.add( + PendingMessage( + senderNickname = senderNickname, + preview = NotificationTextUtils.buildPrivateMessagePreview(message), + timestamp = message.timestamp.time + ) + ) + + postConversationNotification(senderPeerID) + if (pendingMessages.size > 1) { + postSummaryNotification() + } + } + + @Synchronized + fun clearConversation(peerID: String) { + pendingMessages.remove(peerID) + notificationManager.cancel(conversationNotificationId(peerID)) + updateSummaryNotification() + } + + private fun postConversationNotification(peerID: String) { + val messages = pendingMessages[peerID] ?: return + val latest = messages.lastOrNull() ?: return + val sender = Person.Builder() + .setName(latest.senderNickname) + .setKey(peerID) + .build() + val user = Person.Builder() + .setName(appContext.getString(R.string.app_name)) + .build() + val style = NotificationCompat.MessagingStyle(user) + .setConversationTitle(latest.senderNickname) + messages.takeLast(5).forEach { pending -> + style.addMessage(pending.preview, pending.timestamp, sender) + } + + val contentIntent = PendingIntent.getActivity( + appContext, + conversationNotificationId(peerID), + Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(EXTRA_OPEN_DM, true) + putExtra(EXTRA_PEER_ID, peerID) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val publicVersion = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(appContext.getString(R.string.notification_new_private_message)) + .setContentText(appContext.getString(R.string.notification_private_message_hidden)) + .build() + + val notification = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(latest.senderNickname) + .setContentText(latest.preview) + .setContentIntent(contentIntent) + .setStyle(style) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + .setAutoCancel(true) + .setOnlyAlertOnce(messages.size > 1) + .setWhen(latest.timestamp) + .setShowWhen(true) + .setGroup(GROUP_KEY_DM) + .addPerson(sender) + .build() + + try { + notificationManager.notify(conversationNotificationId(peerID), notification) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun postSummaryNotification() { + val totalMessages = pendingMessages.values.sumOf { it.size } + val contentIntent = PendingIntent.getActivity( + appContext, + SUMMARY_NOTIFICATION_ID, + Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val summary = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(appContext.getString(R.string.app_name)) + .setContentText( + appContext.resources.getQuantityString( + R.plurals.notification_private_message_summary, + totalMessages, + totalMessages + ) + ) + .setContentIntent(contentIntent) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY_DM) + .setGroupSummary(true) + .build() + try { + notificationManager.notify(SUMMARY_NOTIFICATION_ID, summary) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun updateSummaryNotification() { + if (pendingMessages.size > 1) { + if (canPostNotifications()) postSummaryNotification() + } else { + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } + } + + private fun createMessageChannel() { + val channel = NotificationChannel( + MESSAGE_CHANNEL_ID, + appContext.getString(R.string.message_channel_name), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = appContext.getString(R.string.message_channel_description) + enableVibration(true) + setShowBadge(true) + lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE + } + appContext.getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + + private fun canPostNotifications(): Boolean { + val permissionGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + appContext, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + return permissionGranted && notificationManager.areNotificationsEnabled() + } + + private fun conversationNotificationId(peerID: String): Int { + return CONVERSATION_NOTIFICATION_ID_BASE + (peerID.hashCode() and 0x3FFFFFFF) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt new file mode 100644 index 00000000..b0edf915 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt @@ -0,0 +1,18 @@ +package com.bitchat.watch.notification + +/** + * Pure notification decisions shared by the watch service and unit tests. + */ +object WearNotificationPolicy { + fun shouldNotifyPrivateMessage( + senderPeerID: String, + senderIsSystem: Boolean, + appInForeground: Boolean, + openDmPeer: String? + ): Boolean { + if (senderIsSystem) return false + return !appInForeground || openDmPeer != senderPeerID + } + + fun activePeerCount(peers: Collection): Int = peers.distinct().size +} diff --git a/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt new file mode 100644 index 00000000..d1fdac0e --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt @@ -0,0 +1,168 @@ +package com.bitchat.watch.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.MainActivity +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.notification.WearNotificationPolicy +import com.bitchat.watch.ui.WearChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Keeps the BLE mesh (scan + advertise + GATT) alive while the app is backgrounded or the watch + * goes ambient. Bluetooth mesh only; no internet connectivity is used or declared. + */ +class WearMeshForegroundService : Service() { + + companion object { + const val CHANNEL_ID = "bitchat_mesh" + const val NOTIFICATION_ID = 1 + } + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var notificationCoordinator: WearNotificationCoordinator + private lateinit var mesh: WearMeshService + private var peerCountJob: Job? = null + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + notificationCoordinator = WearNotificationCoordinator.getInstance(applicationContext) + mesh = WearMeshService.getOrCreate(applicationContext) + createChannel() + startForeground(WearNotificationPolicy.activePeerCount(AppStateStore.peers.value)) + observePeerCount() + mesh.onPrivateMessage = ::handlePrivateMessage + mesh.startServices() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + updateForegroundNotification( + WearNotificationPolicy.activePeerCount(AppStateStore.peers.value) + ) + return START_STICKY + } + + override fun onDestroy() { + peerCountJob?.cancel() + peerCountJob = null + if (::mesh.isInitialized) { + mesh.onPrivateMessage = null + mesh.stopServices() + } + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.mesh_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.mesh_channel_description) + setShowBadge(false) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun startForeground(activePeers: Int) { + val notification = buildNotification(activePeers) + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + } + + private fun observePeerCount() { + peerCountJob = serviceScope.launch { + AppStateStore.peers + .map(WearNotificationPolicy::activePeerCount) + .distinctUntilChanged() + .collect(::updateForegroundNotification) + } + } + + private fun updateForegroundNotification(activePeers: Int) { + if (!canPostNotifications()) return + try { + notificationManager.notify(NOTIFICATION_ID, buildNotification(activePeers)) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun handlePrivateMessage(message: BitchatMessage) { + val senderPeerID = message.senderPeerID ?: return + if (message.sender == "system") return + + WearChatState.onPrivateMessageArrived(senderPeerID) + val senderNickname = mesh.getPeerNickname(senderPeerID) + ?: message.sender.takeIf { it.isNotBlank() && it != senderPeerID } + ?: senderPeerID.take(8) + notificationCoordinator.onPrivateMessage( + message = message, + senderPeerID = senderPeerID, + senderNickname = senderNickname + ) + } + + private fun buildNotification(activePeers: Int): Notification { + val launchIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.app_name)) + .setContentText( + resources.getQuantityString( + R.plurals.mesh_notification_text, + activePeers, + activePeers + ) + ) + .setContentIntent(launchIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + private fun canPostNotifications(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt new file mode 100644 index 00000000..03943ea0 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt @@ -0,0 +1,34 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow + +/** + * Scroll-aware bottom-bar visibility (typical dynamic-hide pattern): the bar hides while the + * user scrolls into history and reappears when they scroll back toward the newest messages. + * Always visible at the bottom (newest). + * + * Lists are normal (top-down) scrollables: value 0 = oldest, maxValue = newest (visual bottom). + */ +@Composable +fun rememberBottomBarVisibility(scrollState: ScrollState): State { + val visible = remember { mutableStateOf(true) } + LaunchedEffect(scrollState) { + var last = 0 + snapshotFlow { scrollState.value to scrollState.maxValue }.collect { (value, max) -> + val atNewest = max - value < 40 + when { + atNewest -> visible.value = true + value < last - 24 -> visible.value = false // scrolling up into history + value > last + 24 -> visible.value = true // back down toward newest + } + last = value + } + } + return visible +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt new file mode 100644 index 00000000..7111cd1c --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -0,0 +1,247 @@ +package com.bitchat.watch.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Mic +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.watch.ui.media.WaveformBars +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +/** + * Native Wear bottom action bar (designed for the ScreenScaffold `edgeButton` slot): a keyboard + * button that opens the text input screen, and a push-to-talk mic button — press and hold to + * record, release to send. Recording state lives in [VoiceNoteController], hoisted at screen + * level so [VoiceRecordOverlay] can render full-screen outside this slot. + */ +@Composable +fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Modifier = Modifier) { + val context = LocalContext.current + val palette = LocalBitchatPalette.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> if (granted) voice.start() } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(palette.inputButton) + .clickable { onKeyboard() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Keyboard, + contentDescription = "type message", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background( + if (voice.recording) MaterialTheme.colorScheme.primary else palette.inputButton + ) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val granted = ContextCompat.checkSelfPermission( + context, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + voice.start() + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + // Only a clean release stops here. If the scroll parent steals + // the pointer mid-drag (cancel), keep recording — the + // screen-level release watcher in ChatScaffold stops when the + // finger actually lifts, anywhere on the screen. + if (tryAwaitRelease()) { + voice.stop(send = true) + } + } + ) + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = "push to talk", + tint = if (voice.recording) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + } +} + +/** + * Full-screen push-to-talk overlay: fades in over the chat with a live waveform, elapsed time, + * and a release hint. Rendered as a sibling of the screen content (NOT inside the edgeButton + * slot, which would clip it to the slot bounds). + * + * The big mic button doubles as the slide-to-cancel target: when the user's finger approaches + * it ([hoveringCancel]), it snaps into a red cancel button with a bouncy spring; lifting the + * finger there cancels the recording, dragging back out returns to send mode. + */ +@Composable +fun VoiceRecordOverlay( + voice: VoiceNoteController, + hoveringCancel: Boolean, + proximity: Float, + magnetPull: Offset, + onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit +) { + val palette = LocalBitchatPalette.current + // The cancel morph, choreographed for feel: + // - color flows green→red CONTINUOUSLY as the finger approaches (finger-driven, so it + // is perfectly fluid), completing to full red on activation + // - the button leans toward the approaching finger (magnetic pull), chasing it with a + // smooth spring so it lags and settles naturally + // - scale blooms with a soft bounce on activation — no rotation, no wobble + val cancelScale by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity, + animationSpec = androidx.compose.animation.core.spring( + dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, + stiffness = androidx.compose.animation.core.Spring.StiffnessMedium + ), + label = "cancelSnap" + ) + val pull by androidx.compose.animation.core.animateOffsetAsState( + targetValue = magnetPull, + animationSpec = androidx.compose.animation.core.spring( + dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, + stiffness = androidx.compose.animation.core.Spring.StiffnessMedium + ), + label = "magnetPull" + ) + val cancelColor = androidx.compose.ui.graphics.lerp( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.error, + if (hoveringCancel) 1f else proximity * 0.85f + ) + AnimatedVisibility( + visible = voice.recording, + enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)), + exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f)) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .onGloballyPositioned { coords -> + onCancelBounds( + androidx.compose.ui.geometry.Rect( + coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero), + coords.size.toSize() + ) + ) + } + .size(52.dp) + .graphicsLayer { + translationX = pull.x + translationY = pull.y + scaleX = cancelScale + scaleY = cancelScale + } + .clip(CircleShape) + .background(cancelColor), + contentAlignment = Alignment.Center + ) { + androidx.compose.animation.Crossfade( + targetState = hoveringCancel, + animationSpec = tween(BitchatMotion.STANDARD_MS), + label = "cancelIcon" + ) { cancel -> + Icon( + imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic, + contentDescription = if (cancel) "cancel recording" else null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(26.dp) + ) + } + } + WaveformBars( + samples = voice.liveSamples, + progress = 1f, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth() + .height(44.dp) + ) + Text( + text = "%d:%02d".format( + voice.elapsedMs / 1000 / 60, + voice.elapsedMs / 1000 % 60 + ) + " / 0:10", + style = ChatVisualTokens.SenderStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 10.dp) + ) + Text( + text = if (hoveringCancel) "Release to cancel" else "Lift finger to send", + style = ChatVisualTokens.SystemActionStyle, + color = if (hoveringCancel) MaterialTheme.colorScheme.error + else palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt new file mode 100644 index 00000000..5f740f12 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -0,0 +1,300 @@ +package com.bitchat.watch.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight +import com.bitchat.android.model.BitchatMessage +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +/** + * The shared chat body for global chat and DM threads, following the classic messenger + * pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary, + * scrollbar) with the header and action bar as floating overlays that get out of the way + * while scrolling up into history and return on any downward scroll; at the newest message + * they are always visible. + * + * The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or + * hiding them never changes the scroll geometry. Earlier revisions animated the bottom + * clearance and resized the header in the layout path, which shifted content under the + * user's finger mid-gesture (felt as "resistance") and fed back into the dock detection. + */ +@Composable +fun ChatScaffold( + messages: List, + myPeerID: String, + emptyText: String, + voice: VoiceNoteController, + onOpenImage: (String) -> Unit, + header: @Composable (expanded: Boolean) -> Unit, + actionBar: @Composable () -> Unit +) { + val columnState = rememberTransformingLazyColumnState() + + // Haptics on incoming messages. + val context = LocalContext.current + var previousCount by remember { mutableStateOf(messages.size) } + LaunchedEffect(messages.size) { + if (messages.size > previousCount) { + val last = messages.lastOrNull() + if (last != null && last.senderPeerID != myPeerID) { + WearHaptics.knock(context) + } + } + previousCount = messages.size + } + + // One state drives both overlays: visible at the bottom or when scrolling toward it, + // hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately + // small so the controls answer every flick immediately. + var atNewest by remember { mutableStateOf(true) } + val controlsVisible = remember { mutableStateOf(true) } + LaunchedEffect(columnState) { + var lastPosition = -1 + snapshotFlow { + val first = columnState.layoutInfo.visibleItems.firstOrNull() + Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0) + }.collect { (canScrollForward, index, offset) -> + val position = index * 100_000 + offset + atNewest = !canScrollForward + if (!canScrollForward) { + controlsVisible.value = true + } else if (lastPosition >= 0) { + when { + position > lastPosition + 24 -> controlsVisible.value = true + position < lastPosition - 24 -> controlsVisible.value = false + } + } + lastPosition = position + } + } + + // Stick to bottom: follow new messages while resting at the newest. + LaunchedEffect(columnState, messages.size) { + if (messages.isNotEmpty() && atNewest) { + // scrollBy to the end of the range: animateScrollToItem stops as soon as the + // item is partially visible, which left the last message cropped. + columnState.scroll { scrollBy(Float.MAX_VALUE) } + } + } + + ChatBody( + messages = messages, + myPeerID = myPeerID, + emptyText = emptyText, + voice = voice, + onOpenImage = onOpenImage, + columnState = columnState, + controlsVisible = controlsVisible.value, + header = header, + actionBar = actionBar, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +private fun ChatBody( + messages: List, + myPeerID: String, + emptyText: String, + voice: VoiceNoteController, + onOpenImage: (String) -> Unit, + columnState: TransformingLazyColumnState, + controlsVisible: Boolean, + header: @Composable (expanded: Boolean) -> Unit, + actionBar: @Composable () -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val context = LocalContext.current + val transformationSpec = rememberTransformationSpec() + + // Slide-to-cancel: while recording, the finger's position is tracked globally; the + // overlay's mic button reports its bounds and becomes the cancel target when the + // finger hovers it (with generous slack so the snap engages on approach). + var cancelBounds by remember { mutableStateOf(null) } + var fingerPos by remember { mutableStateOf(Offset.Zero) } + var fingerActive by remember { mutableStateOf(false) } + val hoveringCancel = fingerActive && + cancelBounds?.inflate(CANCEL_HOVER_SLANT_PX)?.contains(fingerPos) == true + + // Magnetic attraction: as the finger approaches the target (but is not on it yet), the + // button leans toward the finger and blushes red in proportion to the closeness; + // only actually entering the activation zone snaps it into full cancel mode. + val cancelCenter = cancelBounds?.center + val proximity: Float + val magnetPull: Offset + if (fingerActive && cancelCenter != null) { + val toFinger = fingerPos - cancelCenter + val dist = toFinger.getDistance() + proximity = ((MAGNET_OUTER_PX - dist) / (MAGNET_OUTER_PX - MAGNET_INNER_PX)) + .coerceIn(0f, 1f) + magnetPull = if (dist > 1f) toFinger * (proximity * MAGNET_PULL_PX / dist) + else Offset.Zero + } else { + proximity = 0f + magnetPull = Offset.Zero + } + + // Tactile tick each time the finger enters or leaves the cancel target. + var hoverHapticState by remember { mutableStateOf(false) } + LaunchedEffect(hoveringCancel, voice.recording) { + if (!voice.recording) { + hoverHapticState = false + } else if (hoveringCancel != hoverHapticState) { + WearHaptics.tick(context) + hoverHapticState = hoveringCancel + } + } + + Box( + modifier = modifier + .fillMaxSize() + // Push-to-talk release is tracked globally: once recording, lifting the finger + // ANYWHERE on the screen stops — sending, or cancelling when hovering the + // cancel target. On a 1.4" round screen it is too easy to drift off the small + // mic button (the scrollable parent steals the pointer mid-drag), so the + // button alone must not own the release. + .pointerInput(voice) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (!voice.recording) continue + val change = event.changes.firstOrNull() ?: continue + fingerPos = change.position + fingerActive = true + if (event.changes.any { it.changedToUp() }) { + val cancel = cancelBounds + ?.inflate(CANCEL_HOVER_SLANT_PX) + ?.contains(change.position) == true + fingerActive = false + if (cancel) { + WearHaptics.reject(context) + voice.stop(send = false) + } else { + voice.stop(send = true) + } + } + } + } + } + ) { + ScreenScaffold(scrollState = columnState) { + TransformingLazyColumn( + state = columnState, + modifier = Modifier.fillMaxSize(), + // Arrangement.Bottom anchors short content to the bottom: the first message + // starts just above the action bar and new messages push history upward. + // The padding reserves permanent room for the floating header and action + // bar; being constant, it never disturbs an in-flight scroll gesture. + verticalArrangement = Arrangement.Bottom, + contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp) + ) { + if (messages.isEmpty()) { + item { + Text( + text = emptyText, + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 48.dp) + ) + } + } + items(messages, key = { it.id }) { message -> + MessageItem( + message = message, + myPeerID = myPeerID, + onOpenImage = onOpenImage, + modifier = Modifier + .transformedHeight(this, transformationSpec) + .graphicsLayer { + with(transformationSpec) { + applyContainerTransformation(scrollProgress) + } + } + ) + } + } + } + + // The header stays put and shrinks to its dense form instead of disappearing; + // as an overlay its size animation never touches the list's scroll geometry. + Box(modifier = Modifier.align(Alignment.TopCenter)) { + header(controlsVisible) + } + + AnimatedVisibility( + visible = controlsVisible, + modifier = Modifier.align(Alignment.BottomCenter), + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(BitchatMotion.STANDARD_MS) + ) + fadeIn(animationSpec = tween(BitchatMotion.STANDARD_MS)), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(BitchatMotion.STANDARD_MS) + ) + fadeOut(animationSpec = tween(BitchatMotion.STANDARD_MS)) + ) { + Box(modifier = Modifier.padding(bottom = 10.dp)) { + actionBar() + } + } + + VoiceRecordOverlay( + voice = voice, + hoveringCancel = hoveringCancel, + proximity = proximity, + magnetPull = magnetPull, + onCancelBounds = { cancelBounds = it } + ) + } +} + +// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap +// engages as the finger approaches, not only on exact contact. +private const val CANCEL_HOVER_SLANT_PX = 56f +// Magnetic zone geometry (px at watch density): the button starts reacting at +// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation +// boundary); it leans toward the finger by up to MAGNET_PULL_PX. +private const val MAGNET_OUTER_PX = 170f +private const val MAGNET_INNER_PX = 104f +private const val MAGNET_PULL_PX = 22f diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt new file mode 100644 index 00000000..7c902ea5 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -0,0 +1,252 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MailOutline +import androidx.compose.material.icons.filled.People +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults +import androidx.wear.compose.foundation.rotary.rotaryScrollable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.lazy.items +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.media.FileMessageChip +import com.bitchat.watch.ui.media.FullScreenImageViewer +import com.bitchat.watch.ui.media.ImageMessageItem +import com.bitchat.watch.ui.media.VoiceNoteItem +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { + val messages by AppStateStore.publicMessages.collectAsState() + val peers by AppStateStore.peers.collectAsState() + val unreadDms by WearChatState.unreadDms.collectAsState() + val mesh = WearMeshService.peek() + val myPeerID = mesh?.myPeerID ?: "" + var viewerPath by remember { mutableStateOf(null) } + val voice = rememberVoiceNoteController { path -> + mesh?.let { sendVoiceNote(it, null, path) } + } + + ChatScaffold( + messages = messages, + myPeerID = myPeerID, + emptyText = "No messages yet\nSay hi to the mesh", + voice = voice, + onOpenImage = { viewerPath = it }, + header = { expanded -> + ChatHeader( + peerCount = peers.size, + unreadDms = unreadDms.values.sum(), + expanded = expanded, + onOpenPeople = onOpenPeople + ) + }, + actionBar = { + ChatActionBar(onKeyboard = onOpenTextInput, voice = voice) + } + ) + + viewerPath?.let { path -> + FullScreenImageViewer(path = path, onClose = { viewerPath = null }) + } +} + +@Composable +private fun ChatHeader( + peerCount: Int, + unreadDms: Int, + expanded: Boolean, + onOpenPeople: () -> Unit +) { + // Floating title row: full-size at the newest messages, shrinks to its dense form + // while scrolling up into history. Rendered as an overlay, so the animation only + // relayouts this row, never the message list. + val spec = androidx.compose.animation.core.tween( + BitchatMotion.STANDARD_MS + ) + val iconSize by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon" + ) + val titleSize by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle" + ) + val vPadding by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad" + ) + + // The entire header region opens the People screen. When there are unread DMs the + // title gives way so the people and mail icons (with counts) fit side by side on the + // round screen instead of clipping at the edges. + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenPeople() } + .padding(horizontal = 8.dp, vertical = vPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (unreadDms == 0) { + Text( + text = "bitchat", + style = MaterialTheme.typography.titleSmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() }, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 8.dp) + ) + } + Icon( + imageVector = Icons.Filled.People, + contentDescription = "people", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(iconSize) + ) + Text( + text = "$peerCount", + style = MaterialTheme.typography.bodySmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { + (iconSize.value * 0.85f).dp.toSp() + }, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 2.dp) + ) + if (unreadDms > 0) { + Icon( + imageVector = Icons.Filled.MailOutline, + contentDescription = "$unreadDms unread messages", + tint = LocalBitchatPalette.current.accentOrange, + modifier = Modifier + .padding(start = 6.dp) + .size(iconSize) + ) + Text( + text = "$unreadDms", + style = MaterialTheme.typography.bodySmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { + (iconSize.value * 0.85f).dp.toSp() + }, + color = LocalBitchatPalette.current.accentOrange, + modifier = Modifier.padding(start = 2.dp) + ) + } + } +} + +@Composable +fun MessageItem( + message: BitchatMessage, + myPeerID: String, + onOpenImage: (String) -> Unit = {}, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val isSelf = message.senderPeerID == myPeerID + val senderColor = when { + isSelf -> palette.accentOrange + else -> colorForPeer(message.sender + (message.senderPeerID ?: ""), palette) + } + + // Snappy appear animation for incoming messages (BitchatMotion.EMPHASIZED_MS) + var appeared by remember { mutableStateOf(false) } + LaunchedEffect(message.id) { appeared = true } + val alpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS), + label = "msgAlpha" + ) + val offset by androidx.compose.animation.core.animateDpAsState( + targetValue = if (appeared) 0.dp else 6.dp, + animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS), + label = "msgOffset" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 3.dp) + .offset(y = offset) + .alpha(alpha) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isSelf) "you" else message.sender, + style = ChatVisualTokens.SenderStyle, + color = senderColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Text( + text = " ${formatTime(message.timestamp)}", + style = ChatVisualTokens.SystemActionStyle, + fontSize = 9.sp, + color = palette.textTertiary + ) + } + when (message.type) { + BitchatMessageType.Image -> ImageMessageItem( + path = message.content.trim(), + onOpen = onOpenImage + ) + BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim()) + BitchatMessageType.File -> { + val path = message.content.trim() + val file = remember(path) { File(path) } + val sizeBytes = remember(path) { file.length() } + FileMessageChip(name = file.name, sizeBytes = sizeBytes) + } + BitchatMessageType.Message -> Text( + text = message.content, + style = ChatVisualTokens.MessageBodyStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 1.dp) + ) + } + } +} + +private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + +private fun formatTime(date: Date): String = timeFormat.format(date) diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt new file mode 100644 index 00000000..689251a1 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -0,0 +1,150 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults +import androidx.wear.compose.foundation.rotary.rotaryScrollable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.lazy.items +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.ui.media.FullScreenImageViewer +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +@Composable +fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) { + val context = LocalContext.current + val privateMessages by AppStateStore.privateMessages.collectAsState() + val messages = privateMessages[peerID] ?: emptyList() + val mesh = WearMeshService.peek() + val myPeerID = mesh?.myPeerID ?: "" + val palette = LocalBitchatPalette.current + var viewerPath by remember { mutableStateOf(null) } + val voice = rememberVoiceNoteController { path -> + mesh?.let { sendVoiceNote(it, peerID, path) } + } + + val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8) + var sessionEstablished by remember { + mutableStateOf(mesh?.hasEstablishedSession(peerID) == true) + } + + DisposableEffect(peerID) { + WearChatState.openDm(peerID) + WearNotificationCoordinator.getInstance(context).clearConversation(peerID) + onDispose { WearChatState.closeDm() } + } + + LaunchedEffect(peerID) { + if (mesh?.hasEstablishedSession(peerID) != true) { + try { mesh?.initiateNoiseHandshake(peerID) } catch (_: Exception) { } + } + while (true) { + sessionEstablished = mesh?.hasEstablishedSession(peerID) == true + kotlinx.coroutines.delay(2_000) + } + } + + ChatScaffold( + messages = messages, + myPeerID = myPeerID, + emptyText = if (sessionEstablished) "Encrypted channel ready\nSay hi" + else "Setting up encryption…", + voice = voice, + onOpenImage = { viewerPath = it }, + header = { expanded -> + DmHeader( + nickname = nickname, + peerID = peerID, + sessionEstablished = sessionEstablished, + expanded = expanded + ) + }, + actionBar = { + ChatActionBar(onKeyboard = onOpenTextInput, voice = voice) + } + ) + + viewerPath?.let { path -> + FullScreenImageViewer(path = path, onClose = { viewerPath = null }) + } +} + +@Composable +private fun DmHeader( + nickname: String, + peerID: String, + sessionEstablished: Boolean, + expanded: Boolean +) { + val palette = LocalBitchatPalette.current + // Floating title row: full-size at the newest messages, shrinks to its dense form + // while scrolling up into history. Rendered as an overlay, so the animation only + // relayouts this row, never the message list. + val spec = androidx.compose.animation.core.tween( + BitchatMotion.STANDARD_MS + ) + val headerIconSize by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon" + ) + val headerTitleSize by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle" + ) + val headerVPadding by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad" + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = headerVPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = nickname, + style = MaterialTheme.typography.titleSmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { + headerTitleSize.toSp() + }, + fontWeight = FontWeight.Bold, + color = colorForPeer(nickname + peerID, palette) + ) + NoiseLockIcon( + state = if (sessionEstablished) NoiseSessionUiState.Established + else NoiseSessionUiState.Handshaking, + size = headerIconSize, + modifier = Modifier.padding(start = 5.dp) + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt new file mode 100644 index 00000000..68710025 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt @@ -0,0 +1,134 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +/** + * Nickname entry, used both for first-run onboarding and for renaming later. The IME's + * Done action only closes the keyboard so the user can review the name; the confirm + * button is the single commit path. + */ +@Composable +fun NicknameSetupScreen( + initialNickname: String, + title: String = "bitchat", + subtitle: String = "Pick a nickname", + confirmLabel: String = "Join the mesh", + onConfirm: (String) -> Unit +) { + val palette = LocalBitchatPalette.current + // Pre-fill with the cursor at the end of the existing name, not the start. + var name by remember { + mutableStateOf( + TextFieldValue( + text = initialNickname, + selection = TextRange(initialNickname.length) + ) + ) + } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp, bottom = 10.dp) + ) + BasicTextField( + value = name, + onValueChange = { newValue -> + val trimmed = newValue.text.trim().take(24) + name = if (trimmed == newValue.text) { + newValue + } else { + newValue.copy(text = trimmed, selection = TextRange(trimmed.length)) + } + }, + singleLine = true, + textStyle = ChatVisualTokens.MessageBodyStyle.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .clip(RoundedCornerShape(18.dp)) + .background(palette.inputSurface) + .padding(horizontal = 12.dp, vertical = 8.dp), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.Center) { + if (name.text.isEmpty()) { + Text( + text = "Nickname", + style = ChatVisualTokens.MessageBodyStyle, + color = palette.textTertiary + ) + } + innerTextField() + } + } + ) + Button( + onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) }, + enabled = name.text.isNotBlank(), + modifier = Modifier.padding(top = 10.dp) + ) { + Text(confirmLabel) + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt new file mode 100644 index 00000000..2a4bc6b6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt @@ -0,0 +1,76 @@ +package com.bitchat.watch.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +enum class NoiseSessionUiState { Idle, Handshaking, Established } + +/** + * Noise session lock icon, same visual language as the phone's NoiseSessionIcon: quiet grey + * open lock when idle, orange open lock with a soft pulse while the handshake is in flight, + * green closed lock once established. Tint and glyph transitions land together. + */ +@Composable +fun NoiseLockIcon( + state: NoiseSessionUiState, + modifier: Modifier = Modifier, + size: androidx.compose.ui.unit.Dp = 13.dp +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val targetTint = when (state) { + NoiseSessionUiState.Handshaking -> palette.accentOrange + NoiseSessionUiState.Established -> colorScheme.primary + NoiseSessionUiState.Idle -> colorScheme.onSurfaceVariant + } + val tint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(480, easing = FastOutSlowInEasing), + label = "noiseLockTint" + ) + + val pulseAlpha = if (state == NoiseSessionUiState.Handshaking) { + val transition = rememberInfiniteTransition(label = "noiseLockPulse") + transition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(600, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "noiseLockPulseAlpha" + ).value + } else 1f + + Icon( + imageVector = if (state == NoiseSessionUiState.Established) Icons.Filled.Lock + else Icons.Filled.LockOpen, + contentDescription = when (state) { + NoiseSessionUiState.Handshaking -> "handshake in progress" + NoiseSessionUiState.Established -> "encrypted" + NoiseSessionUiState.Idle -> "not encrypted yet" + }, + tint = tint, + modifier = modifier + .size(size) + .alpha(pulseAlpha) + ) +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt new file mode 100644 index 00000000..17568387 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -0,0 +1,98 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +/** + * Internal debug screen (M2): raw peer list with RSSI. Kept for troubleshooting; the real + * people screen arrives in M4. + */ +@Composable +fun PeerDebugScreen() { + val peers by AppStateStore.peers.collectAsState() + val mesh = WearMeshService.peek() + val listState = rememberScalingLazyListState() + val palette = LocalBitchatPalette.current + val nicknames = mesh?.getPeerNicknames() ?: emptyMap() + val rssi = mesh?.getPeerRSSI() ?: emptyMap() + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + item { + ListHeader { + Text( + text = "Peers (${peers.size})", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + } + if (peers.isEmpty()) { + item { + Text( + text = "Scanning for bitchat devices…", + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) + } + } + items(peers) { peerID -> + val nick = nicknames[peerID] ?: peerID.take(8) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = nick, + style = MaterialTheme.typography.bodyMedium, + color = colorForPeer(nick + peerID, palette), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + rssi[peerID]?.let { + Text( + text = "${it}dBm", + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + modifier = Modifier.padding(start = 6.dp) + ) + } + } + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt new file mode 100644 index 00000000..f701a887 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -0,0 +1,201 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MailOutline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +@Composable +fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { + val peers by AppStateStore.peers.collectAsState() + val unread by WearChatState.unreadDms.collectAsState() + val mesh = WearMeshService.peek() + val listState = rememberScalingLazyListState() + val palette = LocalBitchatPalette.current + val nicknames = mesh?.getPeerNicknames() ?: emptyMap() + + // Peers with unread messages float to the top so they are easy to see and reach. + val sortedPeers = androidx.compose.runtime.remember(peers, unread, nicknames) { + peers.sortedWith( + compareByDescending { (unread[it] ?: 0) > 0 } + .thenBy { (nicknames[it] ?: it).lowercase() } + ) + } + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + item { + ListHeader { + Text( + text = "People (${peers.size})", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + } + if (peers.isEmpty()) { + item { + Text( + text = "No one nearby yet\nKeep the app open to mesh", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } + item(key = "self") { + SelfRow( + nickname = mesh?.nickname ?: "me", + onClick = onEditNickname + ) + } + items(sortedPeers, key = { it }) { peerID -> + val nick = nicknames[peerID] ?: peerID.take(8) + PersonRow( + nickname = nick, + peerID = peerID, + encrypted = mesh?.hasEstablishedSession(peerID) == true, + unreadCount = unread[peerID] ?: 0, + onClick = { onOpenDm(peerID) } + ) + } + } + } +} + +@Composable +private fun SelfRow(nickname: String, onClick: () -> Unit) { + val palette = LocalBitchatPalette.current + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 2.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = nickname, + style = ChatVisualTokens.SenderStyle, + color = palette.accentOrange, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Text( + text = " (you)", + style = ChatVisualTokens.SenderStyle, + color = palette.textTertiary + ) + } + Text( + text = "Tap to rename", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } +} + +@Composable +private fun PersonRow( + nickname: String, + peerID: String, + encrypted: Boolean, + unreadCount: Int, + onClick: () -> Unit +) { + val palette = LocalBitchatPalette.current + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = nickname, + style = ChatVisualTokens.SenderStyle, + color = colorForPeer(nickname + peerID, palette), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (encrypted) { + NoiseLockIcon( + state = NoiseSessionUiState.Established, + size = 11.dp, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + if (!encrypted) { + Text( + text = "Tap to chat", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + if (unreadCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 6.dp) + ) { + Icon( + imageVector = Icons.Filled.MailOutline, + contentDescription = "$unreadCount unread messages", + tint = palette.accentOrange, + modifier = Modifier.size(13.dp) + ) + Text( + text = "$unreadCount", + style = ChatVisualTokens.SystemActionStyle, + color = palette.accentOrange, + modifier = Modifier.padding(start = 2.dp) + ) + } + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt new file mode 100644 index 00000000..2b79c98f --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt @@ -0,0 +1,107 @@ +package com.bitchat.watch.ui + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import java.io.File +import java.util.Date +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal fun sendPublicMessage(mesh: WearMeshService, content: String) { + mesh.sendMessage(content) + AppStateStore.addPublicMessage( + BitchatMessage( + sender = mesh.nickname, + content = content, + timestamp = Date(), + senderPeerID = mesh.myPeerID, + deliveryStatus = DeliveryStatus.Sent + ) + ) +} + +/** + * DM send with honest delivery state. MeshCore drops pre-handshake content (it only kicks + * off the Noise handshake), so when no session exists we must not echo "Sent": the echo + * stays "Sending" while a retry loop waits for the session and completes the send. + */ +internal fun sendPrivateMessage( + mesh: WearMeshService, + peerID: String, + recipientNickname: String, + content: String, + scope: CoroutineScope +) { + val established = mesh.hasEstablishedSession(peerID) + val messageID = java.util.UUID.randomUUID().toString() + if (established) { + mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID) + } else { + mesh.initiateNoiseHandshake(peerID) + scope.launch { + val deadline = System.currentTimeMillis() + 15_000 + while (System.currentTimeMillis() < deadline) { + if (mesh.hasEstablishedSession(peerID)) { + mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID) + AppStateStore.updatePrivateMessageStatus(messageID, DeliveryStatus.Sent) + return@launch + } + delay(400) + } + // Session never came up: the echo honestly stays "Sending" (AppStateStore + // refuses status downgrades, so it cannot be marked Failed from here). + } + } + AppStateStore.addPrivateMessage( + peerID, + BitchatMessage( + id = messageID, + sender = mesh.nickname, + content = content, + timestamp = Date(), + isPrivate = true, + recipientNickname = recipientNickname, + senderPeerID = mesh.myPeerID, + deliveryStatus = if (established) DeliveryStatus.Sent else DeliveryStatus.Sending + ) + ) +} + +/** + * Send a recorded voice note. Global chat: broadcast file packet. DM thread: Noise-encrypted + * private file transfer. Local echo renders immediately (content = local path, type = Audio). + */ +internal fun sendVoiceNote(mesh: WearMeshService, peerID: String?, path: String) { + val file = File(path) + if (!file.isFile) return + val packet = BitchatFilePacket( + fileName = file.name, + fileSize = file.length(), + mimeType = "audio/mp4", + content = file.readBytes() + ) + if (peerID == null) { + mesh.sendFileBroadcast(packet) + } else { + mesh.sendFilePrivateEncrypted(peerID, packet) + } + val echo = BitchatMessage( + sender = mesh.nickname, + content = path, + type = BitchatMessageType.Audio, + timestamp = Date(), + isPrivate = peerID != null, + senderPeerID = mesh.myPeerID, + deliveryStatus = DeliveryStatus.Sent + ) + if (peerID == null) { + AppStateStore.addPublicMessage(echo) + } else { + AppStateStore.addPrivateMessage(peerID, echo) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt new file mode 100644 index 00000000..f73891f9 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt @@ -0,0 +1,159 @@ +package com.bitchat.watch.ui + +import android.app.Activity +import android.content.Intent +import android.speech.RecognizerIntent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Mic +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.IconButton +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +/** + * Full-screen text input: field auto-focused so the watch IME (with its built-in dictation) + * opens immediately, plus a dedicated dictation button using the system speech recognizer. + */ +@Composable +fun TextInputScreen(onSend: (String) -> Unit) { + val palette = LocalBitchatPalette.current + val context = androidx.compose.ui.platform.LocalContext.current + var text by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current + + val dictationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val spoken = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + if (!spoken.isNullOrBlank()) { + WearHaptics.tick(context) + onSend(spoken.trim()) + } + } + } + + fun send() { + val trimmed = text.trim() + if (trimmed.isNotEmpty()) { + keyboardController?.hide() + WearHaptics.tick(context) + onSend(trimmed) + text = "" + } + } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center + ) { + BasicTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + textStyle = ChatVisualTokens.MessageBodyStyle.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { send() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .clip(RoundedCornerShape(18.dp)) + .background(palette.inputSurface) + .padding(horizontal = 14.dp, vertical = 10.dp), + decorationBox = { innerTextField -> + Box { + if (text.isEmpty()) { + Text( + text = "Message", + style = ChatVisualTokens.MessageBodyStyle, + color = palette.textTertiary + ) + } + innerTextField() + } + } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { + dictationLauncher.launch( + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM + ) + putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak your message") + } + ) + }, + modifier = Modifier.size(38.dp) + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = "dictate", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { send() }, + enabled = text.isNotBlank(), + modifier = Modifier.size(38.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "send", + tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary + else palette.textTertiary + ) + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt new file mode 100644 index 00000000..7080c933 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -0,0 +1,92 @@ +package com.bitchat.watch.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.bitchat.android.features.voice.VoiceRecorder +import com.bitchat.android.features.voice.normalizeAmplitudeSample +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val MAX_RECORDING_MS = 10_000L +private const val MIN_RECORDING_MS = 600L +private const val AMPLITUDE_POLL_MS = 80L +private const val LIVE_BARS = 32 + +/** + * Push-to-talk recording controller: start on press, stop on release, 10 s cap, ~80 ms + * amplitude polls into a rolling live-waveform buffer. Hoisted to screen level so the + * full-screen overlay can render outside the edge-button slot. + */ +class VoiceNoteController( + private val context: Context, + private val scope: CoroutineScope, + private val onSendVoice: (String) -> Unit +) { + private val recorder = VoiceRecorder(context.applicationContext) + + var recording by mutableStateOf(false) + private set + var elapsedMs by mutableLongStateOf(0L) + private set + var liveSamples by mutableStateOf(FloatArray(LIVE_BARS)) + private set + + private var pollJob: Job? = null + private var startedAt = 0L + + fun start() { + if (recording) return + recorder.start() ?: return + startedAt = System.currentTimeMillis() + elapsedMs = 0L + liveSamples = FloatArray(LIVE_BARS) + recording = true + WearHaptics.knock(context) + pollJob = scope.launch { + while (true) { + delay(AMPLITUDE_POLL_MS) + val amp = normalizeAmplitudeSample(recorder.pollAmplitude()) + liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp + val elapsed = System.currentTimeMillis() - startedAt + elapsedMs = elapsed + if (elapsed >= MAX_RECORDING_MS) { + stop(send = true) + break + } + } + } + } + + fun stop(send: Boolean) { + if (!recording) return + recording = false + // The send path clicks; the cancel path stays silent here because the caller + // plays its own reject haptic. + if (send) WearHaptics.click(context) + pollJob?.cancel() + pollJob = null + val file = recorder.stop() + val elapsed = System.currentTimeMillis() - startedAt + if (send && file != null && elapsed >= MIN_RECORDING_MS) { + onSendVoice(file.absolutePath) + } else { + file?.delete() + } + } +} + +@Composable +fun rememberVoiceNoteController(onSendVoice: (String) -> Unit): VoiceNoteController { + val context = LocalContext.current + val scope = rememberCoroutineScope() + return remember { VoiceNoteController(context, scope, onSendVoice) } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt new file mode 100644 index 00000000..16dc2c0a --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt @@ -0,0 +1,43 @@ +package com.bitchat.watch.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide UI state for the watch app: unread DM counters and the currently open DM thread. + */ +object WearChatState { + private val _unreadDms = MutableStateFlow>(emptyMap()) + val unreadDms: StateFlow> = _unreadDms.asStateFlow() + + @Volatile + var appInForeground: Boolean = false + private set + + @Volatile + var openDmPeer: String? = null + + @Synchronized + fun onPrivateMessageArrived(peerID: String) { + if (appInForeground && openDmPeer == peerID) return + _unreadDms.value = _unreadDms.value + (peerID to ((_unreadDms.value[peerID] ?: 0) + 1)) + } + + fun setAppInForeground(inForeground: Boolean) { + appInForeground = inForeground + } + + @Synchronized + fun openDm(peerID: String) { + openDmPeer = peerID + _unreadDms.value = _unreadDms.value - peerID + } + + @Synchronized + fun closeDm() { + openDmPeer = null + } + + fun unreadCount(peerID: String): Int = _unreadDms.value[peerID] ?: 0 +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt new file mode 100644 index 00000000..6e8fc8e2 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt @@ -0,0 +1,35 @@ +package com.bitchat.watch.ui + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager + +/** + * Tactile accents for the watch's important moments. Uses predefined vibration effects so + * the feel stays consistent with the rest of Wear OS. + */ +object WearHaptics { + /** Firm knock: recording started, message received. */ + fun knock(context: Context) = vibrate(context, VibrationEffect.EFFECT_HEAVY_CLICK) + + /** Crisp click: recording stopped, message sent. */ + fun click(context: Context) = vibrate(context, VibrationEffect.EFFECT_CLICK) + + /** Double tap: destructive/cancel confirmation. */ + fun reject(context: Context) = vibrate(context, VibrationEffect.EFFECT_DOUBLE_CLICK) + + /** Light tick: small confirmations. */ + fun tick(context: Context) = vibrate(context, VibrationEffect.EFFECT_TICK) + + private fun vibrate(context: Context, effect: Int) { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Vibrator::class.java) + } ?: return + vibrator.vibrate(VibrationEffect.createPredefined(effect)) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt new file mode 100644 index 00000000..c2fbf983 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -0,0 +1,306 @@ +package com.bitchat.watch.ui.media + +import android.graphics.BitmapFactory +import android.media.MediaPlayer +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.android.features.voice.AudioWaveformExtractor +import com.bitchat.android.features.voice.VoiceWaveformCache +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import kotlinx.coroutines.delay +import java.io.File + +/** + * Compact inline image thumbnail; tap opens the full-screen viewer. + */ +@Composable +fun ImageMessageItem(path: String, onOpen: (String) -> Unit) { + val bitmap = remember(path) { BitmapFactory.decodeFile(path) } + if (bitmap == null) { + FileMessageChip(name = File(path).name, sizeBytes = File(path).length()) + return + } + Image( + painter = BitmapPainter(bitmap.asImageBitmap()), + contentDescription = "image", + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(top = 2.dp) + .widthIn(max = 120.dp) + .aspectRatio( + (bitmap.width.toFloat() / bitmap.height.toFloat()).coerceIn(0.6f, 1.8f) + ) + .clip(RoundedCornerShape(10.dp)) + .clickable { onOpen(path) } + ) +} + +/** + * Full-screen image viewer (mirrors the phone's FullScreenImageViewer): black surface, + * fit-to-screen, tap or swipe-back to dismiss. + */ +@Composable +fun FullScreenImageViewer(path: String, onClose: () -> Unit) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { onClose() }, + contentAlignment = Alignment.Center + ) { + val bitmap = remember(path) { BitmapFactory.decodeFile(path) } + if (bitmap != null) { + Image( + painter = BitmapPainter(bitmap.asImageBitmap()), + contentDescription = "image fullscreen", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "close", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 24.dp) + .size(20.dp) + ) + } + } +} + +/** + * Voice-note bubble: play/pause + waveform (120 bins, extracted locally like the phone) + + * duration/progress. Playback via MediaPlayer. + */ +@Composable +fun VoiceNoteItem(path: String) { + val palette = LocalBitchatPalette.current + var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) } + var playing by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var durationMs by remember { mutableIntStateOf(0) } + val player = remember { MediaPlayer() } + + LaunchedEffect(path) { + if (samples == null) { + AudioWaveformExtractor.extractAsync(path) { extracted -> + if (extracted != null) { + VoiceWaveformCache.put(path, extracted) + samples = extracted + } + } + } + } + + DisposableEffect(path) { + runCatching { + player.reset() + player.setDataSource(path) + player.setOnCompletionListener { + playing = false + progress = 0f + } + player.prepare() + durationMs = player.duration + } + onDispose { + runCatching { if (player.isPlaying) player.stop() } + runCatching { player.release() } + } + } + + LaunchedEffect(playing) { + while (playing) { + progress = if (durationMs > 0) player.currentPosition.toFloat() / durationMs else 0f + delay(100) + } + } + + Row( + modifier = Modifier + .padding(top = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background(palette.inputSurface) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .clickable { + if (playing) { + player.pause() + playing = false + } else { + runCatching { player.start() } + playing = true + } + }, + contentAlignment = Alignment.Center + ) { + if (playing) { + Canvas(modifier = Modifier.size(10.dp)) { + val w = size.width + val h = size.height + drawRoundRect( + color = Color.Black, + topLeft = Offset(0f, 0f), + size = Size(w * 0.35f, h), + cornerRadius = CornerRadius(1.dp.toPx()) + ) + drawRoundRect( + color = Color.Black, + topLeft = Offset(w * 0.65f, 0f), + size = Size(w * 0.35f, h), + cornerRadius = CornerRadius(1.dp.toPx()) + ) + } + } else { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "play", + tint = Color.Black, + modifier = Modifier.size(16.dp) + ) + } + } + WaveformBars( + samples = samples, + progress = progress, + modifier = Modifier + .padding(start = 6.dp) + .weight(1f) + .height(22.dp), + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = palette.textTertiary.copy(alpha = 0.5f) + ) + Text( + text = formatDuration(if (playing) (durationMs * progress).toInt() else durationMs), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + modifier = Modifier.padding(start = 6.dp) + ) + } +} + +@Composable +fun WaveformBars( + samples: FloatArray?, + progress: Float, + modifier: Modifier = Modifier, + activeColor: Color, + inactiveColor: Color +) { + Canvas(modifier = modifier) { + val bars = 32 + val values = samples?.let { com.bitchat.android.features.voice.resampleWave(it, bars) } + ?: FloatArray(bars) { 0.3f } + val barWidth = size.width / (bars * 2 - 1) + for (i in 0 until bars) { + val v = values.getOrElse(i) { 0f }.coerceIn(0.08f, 1f) + val barHeight = size.height * v + val x = i * barWidth * 2 + drawRoundRect( + color = if (i.toFloat() / bars <= progress) activeColor else inactiveColor, + topLeft = Offset(x, (size.height - barHeight) / 2f), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(barWidth / 2f) + ) + } + } +} + +/** + * Compact chip for non-media files. + */ +@Composable +fun FileMessageChip(name: String, sizeBytes: Long) { + val palette = LocalBitchatPalette.current + Row( + modifier = Modifier + .padding(top = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background(palette.inputSurface) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = name, + style = ChatVisualTokens.SystemActionStyle, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = formatSize(sizeBytes), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } +} + +private fun formatDuration(ms: Int): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60) +} + +private fun formatSize(bytes: Long): String = when { + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576f) + bytes >= 1024 -> "%.1f KB".format(bytes / 1024f) + else -> "$bytes B" +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..5ae88981 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt @@ -0,0 +1,38 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class BitchatPalette( + val inputOutline: Color, + val inputOutlineFocused: Color, + val inputSurface: Color, + val inputSurfaceFocused: Color, + val inputButton: Color, + val textTertiary: Color, + val accentOrange: Color, + val accentPurple: Color, + val peerColors: PeerColorStyle, +) + +val DarkBitchatPalette = BitchatPalette( + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), + textTertiary = Color(0xFF6B776B), + accentOrange = Color(0xFFFF9F0A), + accentPurple = Color(0xFFBF5AF2), + peerColors = PeerColorStyle.Dark, +) + +val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } + +object BitchatMotion { + const val QUICK_MS = 120 + const val STANDARD_MS = 180 + const val EMPHASIZED_MS = 240 +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt new file mode 100644 index 00000000..4d8c390b --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt @@ -0,0 +1,35 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import kotlin.math.abs + +@Immutable +data class PeerColorStyle( + val saturation: Float, + val value: Float, +) { + companion object { + val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f) + } +} + +fun colorForPeer(stableKey: String, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in stableKey.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + val style = palette.peerColors + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = style.saturation, + value = style.value + ) +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt new file mode 100644 index 00000000..3f928aba --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt @@ -0,0 +1,42 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.wear.compose.material3.ColorScheme +import androidx.wear.compose.material3.MaterialTheme + +val BitchatWearColorScheme = ColorScheme( + primary = Color(0xFF32D74B), + onPrimary = Color.Black, + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), + onSecondary = Color.Black, + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = Color(0xFFFF9F0A), + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surfaceContainer = Color(0xFF0E150E), + surfaceContainerLow = Color(0xFF0B0B0B), + surfaceContainerHigh = Color(0xFF182118), + onSurface = Color(0xFFF5F5F5), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), + onError = Color.Black, +) + +@Composable +fun BitchatWearTheme(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalBitchatPalette provides DarkBitchatPalette) { + MaterialTheme( + colorScheme = BitchatWearColorScheme, + typography = BitchatWearTypography, + content = content + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt new file mode 100644 index 00000000..ff66567d --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt @@ -0,0 +1,43 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material3.Typography +import com.bitchat.watch.R + +val BitchatFontFamily = FontFamily( + Font(R.font.geist_mono_regular, FontWeight.Normal), + Font(R.font.geist_mono_medium, FontWeight.Medium), + Font(R.font.geist_mono_semibold, FontWeight.SemiBold), + Font(R.font.geist_mono_bold, FontWeight.Bold), +) + +val BitchatWearTypography = Typography( + defaultFontFamily = BitchatFontFamily, +) + +object ChatVisualTokens { + val MessageBodyStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 17.sp, + ) + + val SenderStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + lineHeight = 15.sp, + ) + + val SystemActionStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + ) +} diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..b63113ef --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..dbc4ad64 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_monochrome.xml b/wear/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..a9e8c8af --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_notification.xml b/wear/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..bc355f16 --- /dev/null +++ b/wear/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/font/geist_mono_bold.ttf b/wear/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 00000000..90eb8a86 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_bold.ttf differ diff --git a/wear/src/main/res/font/geist_mono_medium.ttf b/wear/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 00000000..ff49ece5 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_medium.ttf differ diff --git a/wear/src/main/res/font/geist_mono_regular.ttf b/wear/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 00000000..50c9d5a6 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_regular.ttf differ diff --git a/wear/src/main/res/font/geist_mono_semibold.ttf b/wear/src/main/res/font/geist_mono_semibold.ttf new file mode 100644 index 00000000..1b21724d Binary files /dev/null and b/wear/src/main/res/font/geist_mono_semibold.ttf differ diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/values-v31/styles.xml b/wear/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..aa520d34 --- /dev/null +++ b/wear/src/main/res/values-v31/styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 00000000..2a2ef2f3 --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + bitchat + Mesh network + Keeps the Bluetooth mesh running in the background + + Mesh running — %1$d peer + Mesh running — %1$d peers + + Direct messages + Encrypted direct-message alerts + New encrypted message + Unlock to view the message + + %1$d new message + %1$d new messages + + diff --git a/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt new file mode 100644 index 00000000..ab84d5c7 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt @@ -0,0 +1,62 @@ +package com.bitchat.watch.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearNotificationPolicyTest { + + @Test + fun `system private messages never notify`() { + assertFalse( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = true, + appInForeground = false, + openDmPeer = null + ) + ) + } + + @Test + fun `visible matching dm suppresses notification`() { + assertFalse( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = true, + openDmPeer = "peer-a" + ) + ) + } + + @Test + fun `backgrounded matching dm still notifies`() { + assertTrue( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = false, + openDmPeer = "peer-a" + ) + ) + } + + @Test + fun `different visible dm still notifies`() { + assertTrue( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = true, + openDmPeer = "peer-b" + ) + ) + } + + @Test + fun `peer count is distinct`() { + assertEquals(2, WearNotificationPolicy.activePeerCount(listOf("peer-a", "peer-a", "peer-b"))) + } +}