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