Skip to content

Send to watch on Android: set up, send and remove books on a Wear OS watch - #284

Merged
KodeStar merged 11 commits into
mainfrom
feat/wear-phone
Nov 12, 2026
Merged

KodeStar merged 11 commits into
mainfrom
feat/wear-phone

Conversation

@KodeStar

Copy link
Copy Markdown
Owner

The Android phone's half of Send to watch, for the Wear OS app from #282 (Track W4b, v2.7.0). With this, an Android phone sets up a Wear OS watch with no typing, sends and removes books, and shows the watch in Downloads, as the iPhone does for the Apple Watch.

What is in it

  • modules/audiosilo-watch/android/: WearLink.kt is the phone's only Data Layer user. It sends pair, book and remove as urgent DataItems under /audiosilo/v1/to/<node>/, settles the watch's taken receipts (it deletes its own item only on an exact sent_at match; the watch never deletes the phone's items), prunes expired pairs, reads watch-state, and opens Play on the watch (Install on watch). WearLinkService wakes the app for receipts and state. The phone advertises audiosilo_phone_app, kept through release resource shrinking by a res/raw keep file. The pure rules (paths, book key, target watch, snapshot, receipt matching, JSON) are in WearRules.kt with JVM tests. isSupported is false without Play services. When the Data Layer gives no answer, getSnapshot rejects instead of reporting "no watch", so the card and the ledger keep their last state.
  • JS: src/watch/link.ios.ts becomes link.native.ts (same surface on both platforms). watch-model.ts puts sent_at on pair, book and remove (the Apple Watch ignores it; a Swift test proves the decode is unchanged). AppleWatchCard becomes WatchCard; on Android it says "your watch" and offers Install on watch with its own state. Downloads > On your watch on Android follows the W3c rules minus failed: Waiting on your watch, Preparing, Downloading, On your watch. Hidden for kids, API keys and demo, as before. New keys in all six locales; the Apple keys are untouched.
  • .github/workflows/wear.yml also runs :audiosilo-watch:testDebugUnitTest.

Tests and review

  • 28 JVM tests in :audiosilo-watch, jest for sent_at, the wording choice, the Wear card and Downloads section and the ledger, npm run test:watch (245 Swift Core tests), the JS gate, prebuild then :wear:assembleDebug :wear:testDebugUnitTest :wear:assembleRelease :audiosilo-watch:testDebugUnitTest :app:assembleDebug. One jest suite (src/downloads/offline-companion.test.tsx, untouched here) failed once under full-suite load and passes alone.
  • One full review; contract checked against Wear OS app: play downloaded books on the watch and sync listening on its own #282's wear-core and PhoneLink (paths, receipt shape, key hash, capability names, watch-state): no mismatch.

Verified live (Pixel 6a phone + Wear OS 5.1 emulator; no real Wear OS watch)

On the final heads of this branch and #282: Set up with no typing; Send to watch with the watch app open and swiped away, the row going Waiting on your watch, Preparing, Downloading, On your watch; Remove (playback stops, place kept); Send again; the other-account case on the card, in Downloads and on the watch; Install on watch opening Play on the watch; a 2.5.1 server hides Send to watch. An R8 release build keeps the phone's capability. The Apple Watch target still builds (xcodebuild -scheme AudioSiloWatch); the phone's iOS app target was not built.

Pairs with #282 (merged), KodeStar/audiosilo-docs#294, KodeStar/audiosilo-site#65, KodeStar/audiosilo#179.

modules/audiosilo-watch gains android/: the same JS surface as the iOS
WatchConnectivity link, over the Wear Data Layer, so the Android phone can
set up a Wear OS watch and send it books. WearLink.kt is the phone's only
Data Layer user: pair, book and remove go out as DataItems of UTF-8 JSON under
/audiosilo/v1/to/<watchnode>/, the watch's watch-state and taken receipts come
in through WearLinkService (Play services wakes the app) or the running app's
listener. The phone deletes its own item only when a receipt's path and
sent_at match the send it holds, since a cross-device delete loses to the put
when the phone's clock runs ahead. Expired pairs are pruned at every snapshot,
send and start. One watch at a time: the node with the app whose watch-state
this phone saw last, else one nearby. openInstallOnWatch opens the Play
listing on the watch; isSupported is false without Play services.

The rules are pure in WearRules.kt with JVM tests (paths, book keys with
wear-core's vector, the snapshot, the target, receipt matching, pair expiry,
JSON). The phone advertises audiosilo_phone_app for the watch's Setup.

Part of Track W4 (W4b).
src/watch/link.ios.ts becomes link.native.ts, so the Android phone reaches
its Wear OS watch through the same WatchLink as the iPhone; link.ts stays the
web no-op. Every pair, book and remove now carries sent_at (RFC 3339): on Wear
OS it makes each send a new Data Layer item, orders a remove against a later
download and is echoed in the watch's receipt; the Apple Watch ignores the
field.

A watchKind (apple or wear) picks the wording: on Android the card is "Wear
OS watch", Set up says "Set up your watch", Downloads says "On your watch", and
nothing asks for the watch app to be opened, since its listener service takes
what is sent with the app closed. With no app on the watch the card offers
Install on watch, which opens Play there. AppleWatchCard is now WatchCard. The
Downloads section keeps the W3c rules; on Wear OS nothing is ever failed, so
"delivered" means the watch app took it. New keys in all six locales; the
Apple Watch texts are unchanged.

Part of Track W4 (W4b).
The phone-side watch entry named link.ios.ts and said Android had no link.
It now names link.native.ts and gains the Android bullet: WearLink as the one
Data Layer user, the item paths and book key, sent_at, the receipt the phone
acts on, pair pruning, the one-watch target, the phone's capability and the
Wear OS wording. CLAUDE.md's index and layout lines follow.

Part of Track W4 (W4b).
…event

A watch back in reach puts a receipt per item, and both the listener
service and the running app's listener see each one. Every receipt took
the lock, read its item and deleted it alone, and every change sent JS a
full snapshot (five Data Layer round trips), so a send queued behind the
backlog and could answer "no watch" on the 5 s limit.

Receipts from one callback are now settled under one lock against one
read of the phone's items, the snapshot's four reads run together, and
the watchState event is conflated: one snapshot at a time and one more
for whatever changed meanwhile. A snapshot in flight is cancelled with
the observer instead of reading the React context after the module is
destroyed.

A message's place under the target watch (pair, or book/<key>) is named
once before the lock instead of through a dummy node, the book identity
is read in one place, and the async functions use Expo's coroutine form
with coded exceptions.

Part of the Wear OS phone side (W4b).
NO_WATCH_LINK says kind 'apple', and an Android phone without Play
services (or a binary built without the module) fell back to it, so any
wording read off the link would have named an Apple Watch there. The
native link now keeps the platform's kind when there is no way to a
watch.

Also one RFC 3339 helper for a message's times, and the conventions name
the real symbol (watchLink.kind).

Part of the Wear OS phone side (W4b).
The phone advertises audiosilo_phone_app from a string array that only
Play services reads, by name. The tools:keep meant to protect it sat on
the root of the module's values file, and that attribute is dropped when
a library's values are merged, so the release shrinker reported the
array as not reachable and removed it. A release phone app would then
never advertise the capability, and the Wear app would offer "Get
AudioSilo on your phone" with the app installed. Debug builds do not
shrink, so nothing showed it.

The keep is now a res/raw file of its own, which the shrinker does read
from a library (resources.txt: reachable from keep xml file).

Part of the Wear OS phone side (W4b).
The encoder compared against literal U+2028 and U+2029 characters, which
read as two spaces in the source: a formatter or editor that normalises
whitespace would turn the rule into a space check with no visible diff.

Part of the Wear OS phone side (W4b).
Install on watch shared the card's error with Set up and only ever set
a flag, so after one success and one failure the card said both that
Google Play had opened on the watch and that it could not be opened, a
failure stayed under the card once the app was installed, and a second
tap while the watch had yet to answer asked it again.

The ask is now idle, opening, opened or failed: the button shows it is
waiting and ignores taps meanwhile, and each status shows only its own
failure.

Part of the Wear OS phone side (W4b).
The phone now stamps pair, book and remove with sent_at on every
platform (Wear OS needs it). The Apple Watch does not read it; this pins
that a stamped message decodes to the same message as one without, so
the contract's change has a test on both sides.

Part of the Wear OS phone side (W4b).
A Play services call that failed or took over 5 s (the wait for the
link's lock included) made the Android snapshot answer paired: false
with no watch id. JS takes that as the watch's real status: during a
send the book's row was restored away, a second pairing token was minted
for a watch already signed in, and outside a send the card, the
Downloads section and Send to watch vanished until the next event.

getSnapshot now rejects (ERR_NO_ANSWER, or the error) unless the
Wearable API is known to be absent, and the watchState event is skipped,
so refreshWatch keeps its last answer. The sweep at start and the
receipt settle may hold the lock for 30 s at most, so a call that never
answers cannot keep every send waiting, and the sweep asks whether the
Wearable API is there first (no warning at every launch on a phone with
no Wear OS app). A watchState event JS cannot take is logged instead of
ending the process from an unhandled coroutine failure.

The exceptions spell out their codes: R8 renames the classes, so a code
inferred from the name came out as ERR_L90.

Part of the Wear OS phone side (W4b).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant