Merge remote-tracking branch 'origin/main' into codex/persistent-conversations

This commit is contained in:
callebtc 2026-07-29 03:53:34 +02:00
commit e4f0713121
56 changed files with 5080 additions and 41 deletions

View 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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -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" }

View File

@ -17,4 +17,5 @@ dependencyResolutionManagement {
rootProject.name = "bitchat-android"
include(":app")
include(":wear")
// Using published Arti AAR; local module not included

View File

@ -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 addresspeer 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
View 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
View 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

View 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>

View File

@ -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
}
}
}

View File

@ -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()
}
}

View 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>

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View 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")
}
}
}

View 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()
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}

View 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)
)
}
}
}

View 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

View 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)

View 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)
)
}
}

View 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)
}
}
}

View 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)
)
}

View 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)
)
}
}
}
}
}
}

View 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)
)
}
}
}
}
}

View 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)
}
}

View 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
)
}
}
}
}

View File

@ -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) }
}

View 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
}

View 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))
}
}

View 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"
}

View File

@ -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
}

View 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
)
}

View 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
)
}
}

View 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,
)
}

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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>

View 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>

View 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>

View 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>

View File

@ -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")))
}
}