docs/wear-os-implementation-plan.md
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 |
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.: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.INTERNET or location permissions.: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.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:
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, MessagePaddingmodel/** — BitchatMessage, BitchatFilePacket, FragmentPayload, NoiseEncrypted,
IdentityAnnouncement, RoutedPacketnoise/** — NoiseSession, NoiseSessionManager, NoiseEncryptionService,
NoiseChannelEncryption, vendored pure-Java noise/southernstorm/**crypto/** — EncryptionServiceidentity/** — SecureIdentityStateManagermesh/** — BLE stack (BluetoothConnectionManager, GATT server/client managers, broadcaster,
tracker, permission manager), FragmentManager, SecurityManager, PacketProcessor,
MessageHandler, PeerManager, StoreForwardManager, MeshTransport, MeshService,
TransferProgressManager, PrivateMediaTransfer, PowerManagerservices/AppStateStore.kt — process-wide state storeutil/AppConstants.kt — shared constants (GATT UUIDs, fragmentation sizes)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.
Wear Compose Material3 (round-screen safe by default):
TimeText scaffold, rotary crown scrolling.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.RecognizerIntent. Haptic feedback on incoming messages.connectedDevice) keeps scan/advertise
alive; shared PowerManager provides duty-cycling.adb exec-out screencap).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.EncryptedSharedPreferences (androidx.security-crypto) works on Wear OS — identity persistence
is reused as-is.docs/wear-os-implementation-plan.md: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 themegradle/libs.versions.toml; include(":wear") in
settings.gradle.ktsSuccess 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.
wear/build.gradle.kts; resolve transitive dependencies by
extending includes (never by copying Kotlin sources)ui/theme/protocol, noise, crypto, mesh) into :wear test source set./gradlew :app:test :wear:test greenImplementation 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.
BLUETOOTH_SCAN/CONNECT/ADVERTISE), watch-styled screensWearMeshService foreground service (type connectedDevice); wire shared
BluetoothConnectionManager + mesh components; start scanning + advertisingSuccess 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).
BitchatMessages (relay/TTL comes free from shared mesh code)ScalingLazyColumn, message bubbles per bitchat style, peer colors,
timestamps) + composer (IME + RecognizerIntent dictation) + incoming-message hapticsSuccess 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.
EncryptionService/NoiseSessionManager) → DM threadEncryptedSharedPreferences); stale-session detection & automatic
re-handshake after watch app restartSuccess 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).
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)
BitchatFilePacket TLV,
FileUtils.saveIncomingFile, messageTypeForMime — already wired via shared MessageHandler)ImageMessageItem /
FullScreenImageViewerfile_recv to the wear test hook; enable file + file_private scenarios
for the watchVoice notes (first-class)
VoiceRecorder (16 kHz mono AAC, audio/mp4, .m4a)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)BitchatMessageType.Audio, content = local path) render as a
voice-note bubble: play/pause + waveform (shared Waveform.kt extractor, 120 bins) +
duration; MediaPlayer playbackInput redesign (native Wear bottom actions)
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):
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).Result: PASSED —
mesh_lab.py scenario file and file_private (phone→watch) green, SHA-256 digest match.scenario all (7 scenarios) green in ~75 s.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)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 machinerySuccess 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.
BitchatMotion tokens; message-appear animations; screen
transitions; auto-scroll to newest; splash screen (black, on-brand) & app iconPowerManager 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)singleLine IME action, bottom-chord clipping, black splash)done; add a short "how to build/run/test" sectionSuccess 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.
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.
# 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:
mesh_lab raises GateError: ADB command failed, the watch's USB link flapped — retry.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.