The Android lanes build and upload the wear bundle after the phone's, and prepareForRelease writes release notes under the wear version code too.
18 KiB
Hammer for Wear OS
Design doc. Status: phases 1 to 3 implemented on the wear-app branch (sync extraction,
pairing endpoint, watch app with pairing, sign-in, project subscriptions, and background sync).
Phase 4 (capture) is complete: the guards, the capture activity, the expedited sync, the tile and
the complication have all landed. Capture, the tile and the complication are smoke tested end to
end on a Wear OS emulator against a local server, not yet on a watch.
A standalone Wear OS client for capturing notes and ideas while away from a desk, and for
listening to scenes read aloud. It reuses the common data and sync layers unchanged and
presents a watch-native UI. The watch is a full, independent sync client: after a one-time
pairing it talks to the sync server on its own and never depends on the phone.
Goals
- Pick a project and add a note to it in one or two taps, offline, with voice input.
- Capture a Story Idea (no project needed) just as quickly.
- Everything captured offline syncs on its own the next time the watch has a network.
- Listen to a scene read aloud through Bluetooth headphones, with position remembered.
- Reuse
commonfor all data, sync, and domain logic. No second implementation of anything.
Non-goals
- Editing scenes, encyclopedia entries, timelines, or anything beyond notes and ideas.
- Conflict resolution UI on the watch. A project that needs resolution is shown as such and resolved on a phone or desktop.
- Phone-relay transport. The watch does not send notes through the phone app.
- iOS or non-Wear watches.
Architecture decision: independent sync client
Two shapes were considered.
Remote control for the phone. The watch writes notes as Wearable Data Layer items, and the
phone app receives them and runs the existing AddNoteWorker. Cheapest, but the watch is
useless without the phone app installed and reachable, and it can never grow a read-aloud
feature because no content lives on the wrist.
Independent sync client (chosen). The watch runs common, holds its own copy of the
projects it cares about, and syncs directly with the server. The protocol already treats every
device symmetrically, and offline ID collisions from a third device are resolved by
IdConflictResolutionOperation exactly as they are for phone plus desktop today. The phone is
involved only during pairing, to hand the watch a session.
Module layout
wear/ Wear OS application module. Depends on :common only, never on :composeUi.
applicationIdis the phone app's (com.darkrockstudios.apps.hammer.android) so Google Play lists it under the same app. Version codes must be unique across the listing, so the wear module derives itsversionCodefromgetVersionCode(...)plus a fixed offset.- Manifest declares
android.hardware.type.watchandcom.google.android.wearable.standalone = true. - UI: Wear Compose Material 3 (
androidx.wear.compose:compose-material3), Wear Tiles with ProtoLayout, and the complication data source library. Pin versions ingradle/libs.versions.tomlat implementation time; thejetpack-compose-m3skill covers the component vocabulary. play-services-wearableis needed only for the pairing handshake.- Not published to F-Droid initially.
Koin graph
The watch has its own Application that calls startKoin with mainModule,
appModule(scope), and a wearModule. mainModule hard-includes the Android platformModule,
which also wires phone-only services (share service, backup manager, focus mode, platform
settings component). The module is not split: every one of those is a lazy binding resolved only
by composeUi screens the watch never shows, and PlatformSpellCheckerFactory constructs inertly
and degrades to no checkers. wearModule adds only watch pieces (Data Layer client, pairing use
case). The encrypted shared-prefs auth token store works unchanged on Wear.
FileLogger and the global crash handler live in common/src/androidMain so both applications
share them.
On the phone, everything that touches Google Play services (the pairing listener, dialog,
responder, and wearPairingModule) lives in android/src/gms, added to the source set only for
non-F-Droid builds. src/nogms supplies an empty playServicesModules list, and the F-Droid
manifest simply omits the pairing service and activity.
Backups: BackupOperation runs before every project sync. Projects on the watch are small and
few, so keep the behaviour rather than special-casing it; revisit if storage becomes a problem.
Sync orchestration extraction (upstream refactor)
Per-project sync is already usable outside the UI (AddNoteWorker proves it). The
"sync everything" flow is not: account sync, the change probe, parallel project syncs, ideas,
reauth, and idea-conflict callbacks live inside ProjectsListComponent.syncProjects, mixed with
dialog status updates.
Move that orchestration into a common use case (working name SyncAccountUseCase) with:
- an explicit set of projects to sync (the watch passes its subscribed projects; the phone and desktop pass everything),
- progress and log callbacks,
onUnauthorizedandonIdeaConflicthooks,- a result describing per-project outcomes.
ProjectsListComponent becomes a thin caller that maps results onto its dialog state. This is
a pure refactor with no behaviour change and lands first, on its own. It also makes a
WorkManager background sync on the phone trivial later.
Pairing and authentication
Server tokens are stored per (userId, installId), and a refresh replaces the row for that
install. The watch therefore must own its own install ID and its own token; copying the phone's
would make the two devices race each other on refresh. The phone stores no password, so it
cannot log in on the watch's behalf. A new server endpoint is needed:
POST /api/account/{userId}/pair_install
Authorization: Bearer <phone token>
form: installId=<watch install id>
200: Token for the new install
It is createToken(userId, installId) behind bearer auth. A device that holds a session can
already read and write everything, so minting a sibling session grants no new power. Rate limit
it and log it like login.
Handshake, all over the Wearable Data Layer (app-private, carried on the encrypted Bluetooth link):
- Watch generates its install ID via
GlobalSettingsStore.ensureInstallId()and sends apair_requestmessage carrying it. - Phone shows a confirmation, calls
pair_install, and replies with apair_responsemessage containing theServerSettingspayload (url, ssl, email, userId, bearer, refresh) for the watch's install. - Watch stores the settings through the normal
GlobalSettingsStorepath and runs its first account sync.
Use MessageClient, not persisted DataItems, so the token is never left lying in the Data
Layer store. If the watch is unreachable the phone simply reports that and the user retries.
Client side, add AccountUseCase.pairInstall(installId) and the matching ServerAccountApi
call. The watch keeps a manual sign-in screen (server URL, email, password via RemoteInput)
as a fallback for a watch with no Hammer phone app.
Sign out on the watch wipes local settings and projects; the server token expires on its own. The existing audit finding that refresh tokens never expire applies to watch tokens too and is tracked separately.
Local network access
Android 17 blocks an app from reaching local network addresses until it holds
ACCESS_LOCAL_NETWORK, and a self-hosted server is usually on the LAN. A blocked connection does not
fail fast; it times out as a generic connection error, so the permission is settled before
connecting rather than diagnosed after. The watch asks only when the server needs it: the address is
resolved and checked against the private, loopback, link-local, IPv6 unique local, and carrier-grade
NAT ranges (the last covers mesh VPNs such as Tailscale). A name that will not resolve is judged by
how it looks (.local, .lan, .home, .internal, a single label). A server like hammer.ink never
triggers the prompt.
- Manual sign in checks before the login request, since the login is the first thing that touches the server.
- Pairing hands the watch its settings and lands on the projects screen, which holds its app-open sync until the permission is answered. A refusal leaves the sync held with an explanation and an Allow button.
Projects on the watch
Account sync creates a local directory for every server project, with metadata and a server project ID but no entities. Syncing content is opt-in per project:
- The watch keeps its subscribed projects in a wear-owned DataStore (
WearPrefsDatasource, the only writer), keyed by serverProjectIdso a rename does not drop a subscription. It is not aGlobalSettingsfield because that file is shared by every platform. - Only subscribed projects are passed to the sync use case. The change probe keeps repeat syncs to a single request when nothing changed.
- A subscribed project's first sync pulls every entity, one request each. That is acceptable for a novel and is shown with progress.
Notes can only be added to a subscribed, synced project. An unsynced project has an empty
IdAllocator, so a note created there would take ID 1 and collide on every sync. Unsubscribed
projects appear in the picker greyed out with a "sync to enable" action. Ideas have no such
restriction because they are UUID keyed.
Capture flow
- Tile: shows the last project captured to as a button, a "Note" button, a "Story idea"
button, and the count of unsynced items. Tapping the project opens capture on its picker, which
is the only way to change where a note goes without going into the app. The project button lives
in the main slot because the title slot does not receive taps. A tile launch action carrying an
AndroidBooleanExtrais silently dropped along with the whole action, so flags travel as strings. Tiles cannot take input; the buttons launch the activity through aLaunchAction, which is whyCaptureActivityis exported. Built with ProtoLayout Material 3. - Complication: SHORT_TEXT and MONOCHROMATIC_IMAGE, showing the unsynced count (or "Note" when there is nothing waiting) and tapping through to capture.
- Both are cached by the system and re-read only on their own slow cadence, so anything that
changes the pending count calls
CaptureSurfaceUpdater: a capture, and every finished sync. Without it the watch face keeps showing a stale count. - Activity: opens into
RemoteInput(voice first, keyboard fallback) as soon as it knows a capture has somewhere to go, never before: prompting first would take a whole dictated note and then throw it away on the "no project" screen. The project defaults to the last one captured to and is changed from a list, never a required step. Confirm saves throughNotesRepository.createNoteinsidetemporaryProjectTask, exactly likeAddNoteWorker, then enqueues a sync. Saving runs in the app scope, so a capture still lands if the watch drops the activity mid-save. - The
RemoteInputkeyboard opens with shift engaged and offers no way to turn that off, so the sign-in server and email are folded to lowercase. A password cannot be normalised, so a capital there is the user's to fix. - Ideas: the same flow through
IdeasRepository, no project step. - Show the pending count after save so the user knows the note exists locally even though it has not synced.
Protecting captures that have not synced
Everything the watch writes is offline-first, so two flows can destroy writing that never reached the server. Both are guarded before the capture UI exists, because once capture ships the loss is silent.
- Unsubscribing syncs the project first, while it is still in the sync filter, then deletes its content only if the sync journal reports nothing outstanding. If anything remains the project keeps both its content and its subscription: an unsubscribed project is filtered out of every later sync, so dropping the subscription would strand the writing rather than free it. A count that cannot be read is treated as outstanding, so a failure can never authorise a delete.
- Signing out counts the subscribed projects' pending entities plus ideas with no server baseline, and asks for confirmation before wiping. An unreadable count still warns.
loadPendingEntityCount is the source of truth for a project. It reads the journal file without a
project scope, because opening one initialises the scene tree, every scene's content, metadata and
the timeline, which is far too much work to learn how many entities are outstanding. A journal that
cannot be read throws rather than reporting zero: callers use the count to decide whether local
writing is safe to discard, and an unreadable journal is not evidence that it is. The watch reaches
it through UnsyncedContentSource, an interface, so the components can be tested with fakes.
Background sync
WorkManager, mirroring the widget worker:
- A one-time expedited request with a network constraint after every capture, enqueued with
APPEND_OR_REPLACE.REPLACEcancels work that is already running, so a second capture would abort the first one's sync partway through the protocol.SyncCoordinatorreportsBusyrather thanSkippedwhen another sync holds the lock, so work that needs its own captures uploaded asks again instead of reporting success. - The pending count is gathered after the save is confirmed, not before. Nothing that can still fail belongs between writing the user's words and telling them the words are safe.
- A periodic request (charging plus unmetered network) for all subscribed projects.
- A manual "Sync now" in the app.
Read aloud
Phase-gated behind the rest, but designed in now because it depends on synced content.
- A foreground service owning a
TextToSpeechinstance and aMediaSession, so headphone controls and the system media UI work. Register an Ongoing Activity so the watch face shows the session. - Text pipeline: scene markdown to plain speech text. Add a
basehelper next toProseHtmlthat walks the same intellij-markdown tree the PDF renderer uses and emits plain paragraphs, dropping formatting markers and turning headings and scene breaks into pauses. - Chunk by paragraph, staying under
TextToSpeech.getMaxSpeechInputLength(), and queue with utterance IDs so progress is trackable. Persist the last utterance per scene so playback resumes where it stopped. - Controls: play/pause, next/previous scene in outline order, speed.
- Output device: check
AudioManager.getDevices(GET_DEVICES_OUTPUTS)for a Bluetooth headset before starting, pause on disconnect, and offer the media output switcher on Wear OS 5 and later. Allow the watch speaker only when the platform reports one. - Offline: Wear OS ships an on-device TTS engine with seven preloaded languages; other languages download on Wi-Fi while charging. Voice quality is the on-device tier. Use the project's language setting to pick the TTS locale.
Packaging and release
versionNamecomes fromlibs.versions.tomllike every other target.versionCodeis the phone's plus 1,000,000,000 (getWearVersionCode), because every bundle in a Play listing needs a unique code.- The watch app is a second bundle in the phone's listing, released on Play's Wear OS form
factor tracks. Through the API those are
wear:qa(internal testing),wear:betaandwear:production. They exist only once Wear OS is added in the Play Console under Advanced settings > Form factors; until then every upload to them fails. - The Android lanes in
fastlane/Fastfilebuild both bundles and upload the watch's after the phone's, so a Wear OS failure never holds back a phone release.internal,betaandreleasemap towear:qa,wear:betaandwear:production.alphaskips the watch: a form factor has no default closed testing track, only custom-named ones. - The watch upload sends release notes only. The listing is shared, and the phone upload already
sends its text and images, including any
images/wearScreenshots. prepareForReleasewrites the Play release notes under both version codes.build.ymlbuilds and unit-tests the wear module.
Testing
commondesktop tests for the extracted sync use case, driven with the existing fake filesystem and in-memory datasources.- Server route tests for
pair_install(note thatfrontend()and account DI additions ripple into the existing route-test fixtures). - Compose previews for every watch screen, rendered and checked.
- Wear emulator smoke run through the
android-cliskill. - End to end: capture a note on the watch offline, come online, confirm it appears on desktop after sync, and that a colliding note created offline on desktop is re-IDed rather than lost.
Phases
- Sync use case extraction in
common. No behaviour change. Standalone PR. - Pairing endpoint on the server plus the client API and use case. Standalone PR.
- Wear module skeleton: Koin split, pairing flow, manual sign-in fallback, project list with subscribe and sync, sync status.
- Capture (done): guards for unsynced captures, then the activity with
RemoteInput, note and idea flows, tile, complication, WorkManager sync. - Read aloud: markdown to speech helper in
base, playback service, controls. - Release pipeline (done): fastlane lanes, release notes, version code offset.
Phases 1 and 2 improve the phone and desktop clients on their own and should land even if the watch slips.
Open questions
- Does dictation work offline on the target watch? Voice capture is the point of the feature and the emulator cannot answer this; it needs the real hardware.
- Whether backups on the watch stay on or get a no-op datasource once real usage shows the storage cost.