Skip to content

Loopky on the wrist: Wear OS + watchOS review-only clients #73

Description

@jvsena42

A Loopky app for the wrist: Wear OS and watchOS clients whose only job is the review loop
raise your arm, clear the cards that are due, put your arm down. No import, no editing, no
discovery. The pitch is that spaced repetition is the one part of Loopky that is already a
30-second interaction, and the phone is the thing that turns 30 seconds into five minutes of
scrolling past it.

This issue is filed against the main repo because, like loopky-cli (#54), the interesting
decisions are all on the Loopky side: what shared/ has to grow, and how a second device gets a
session and writes SRS state without fighting the phone.

Why a watch, specifically for SRS

  • The study loop is the only screen that is already wrist-sized. StudySessionViewModel shows
    one card, a reveal, and four grade buttons. That is the entire interaction surface.
  • deckId == null already means "everything due". StudySessionViewModel(deckId = null)
    SrsRepository.dueToday() is exactly a watch session: no deck picker, no navigation, just the
    queue. The VM a watch needs is written.
  • Reviews are tiny writes to your own homeserver. SRS state lives at
    /pub/loopky/srs/{authorPubky}/{deckId}/{n}.json on your homeserver
    (PubkyPaths.srsChunk), keyed by the deck's author, so a watch never needs write access to
    anyone else's tree — it needs the same capability set the phone already has.
  • Reviews already batch. SrsRepositoryImpl buffers grades in memory and flushes per chunk,
    with an automatic flush every FLUSH_EVERY (20) reviews. That is the right shape for a device
    with a radio you do not want to wake per card.

What it should do

Four screens, total:

Due today        # "37 cards" + Start; empty state when nothing is due
Study            # front → tap/rotate to reveal → back → Again / Hard / Good / Easy
Session done     # "reviewed 37 · 4 min", flush status
Settings         # signed-in pubky, sync now, sign out

Platform-native, per the repo's native-first rule — Wear Compose Material 3 (ScalingLazyColumn,
SwipeDismissableNavHost, rotary input for reveal/next) on Wear OS, and SwiftUI for watchOS
(Digital Crown, .containerBackground). Grade buttons are the one thing that must not be a
faithful port of the phone design: four targets do not fit a 45mm screen. Options worth
prototyping: two buttons (Again / Good) with a long-press for Hard/Easy, or a swipe-quadrant.

Non-negotiables for a watch client:

  • Works with the phone in another room on LTE/Wi-Fi watches, and degrades to "sync when
    tethered" otherwise (see networking below).
  • Survives being killed mid-session. Wear OS ambient/doze will kill the app between arm
    raises. Unflushed grades must be persisted locally the moment they are made, not held only in
    SrsRepositoryImpl's in-memory dirty set.
  • Haptic on grade. It is the only feedback loop that works when you are not looking.
  • No text entry, ever. Nothing in the flow may require a keyboard.

Auth: there is no QR code on your wrist

The mobile flow (IdentityRepositoryImpl.beginSignIn) starts an auth flow, appends Ring
return-callbacks (x-success=loopky://auth), and deeplinks into Pubky Ring. On a watch, all three
steps break: Ring is a phone app, the callback has nowhere to return to, and the relay poll is
already the flakiest part of sign-in (awaitApprovalWithRetry exists for a reason).

So the watch must not run its own Ring flow. It should receive a session from the paired phone:

  • Wear OS: DataClient/MessageClient over the Data Layer, phone → watch, one-shot on pairing
    and again on re-auth.
  • watchOS: WCSession.transferUserInfo / updateApplicationContext.
  • Store the received Session (identity, sessionSecret, capabilities, homeserver) through
    a watch SecureSessionStore actual — Keystore-backed on Wear OS, Keychain on watchOS. Never
    write it to a DataMap that lingers unencrypted.
  • Session expiry is the hard part. Ring sign-in: the auth-approval retry can never succeed, and it hides the real error #59 already tracks that an expired session needs a fresh
    Ring approval. On the watch that is unrecoverable without the phone: the correct behaviour is to
    stop, keep unflushed grades on disk, and show "open Loopky on your phone" — never silently drop
    reviews.

Open question worth deciding early: does the watch get the same session as the phone (simplest,
but one revocation kills both) or its own capability-scoped session minted by the phone (better,
but there is no delegation primitive for that today)?

Blockers

A. Wear OS — a new wearApp module.

  • shared/ already has androidTarget(), so the repositories, StudySessionViewModel, and the
    Koin graph in SharedModule.kt come for free. This is by far the cheaper of the two platforms.
  • minSdk = 29 clears Wear OS 3 (API 30). No change needed.
  • Size is the real problem. The UniFFI native libs are 16M (arm64-v8a) and 11M
    (armeabi-v7a) in shared/src/androidMain/jniLibs/. A standalone watch APK has to ship one of
    those, on a device where users notice install size. Options: ABI-split the watch variant to
    arm64 only, strip the Rust cdylib harder in pubky-core-ffi-fork, or drop the FFI from the
    watch entirely and proxy every Pubky call over the Data Layer (which then forces the tethered
    model — see D).
  • composeApp is a phone app that happens to be named generically; the watch app is a separate
    module, not a build variant of it, because it shares no UI.

B. watchOS — new Kotlin/Native targets and a new xcframework slice.

  • shared/build.gradle.kts declares iosArm64() + iosSimulatorArm64() only. watchOS needs
    watchosArm64() + watchosSimulatorArm64() added and the Shared framework exported for them.
  • iosApp/iosApp/Frameworks/PubkyCore.xcframework contains ios-arm64 and ios-arm64-simulator
    and nothing else. pubky-core-ffi-fork/build_ios.sh has to grow watchOS slices, which means the
    whole Rust dependency tree (rustls included) must build for aarch64-apple-watchos. This is
    the risk item — assume it does not build first try.
  • ApkgReader is an expect object with no watch actual; it needs a stub (the watch never imports
    .apkg files) or the Anki code has to move behind a source-set boundary that watchOS omits.
  • KVault does not publish a watchOS artifact. implementation(libs.kvault) is declared in
    commonMain, and 1.12.0 ships android + iosArm64/iosSimulatorArm64/iosX64 only, so
    watchosArm64() will not resolve commonMain until that dependency moves down into
    androidMain/iosMain. watchOS then needs its own Keychain-backed SecureSessionStore actual —
    IosSecureSessionStore cannot be lifted into a shared appleMain, because there is nothing to
    lift it onto. Same wall the CLI hits with jvm(); see shared prerequisite 1 below.
  • And iOS itself is still not wired end to end — iOSApp.swift has the Koin bootstrap commented
    out pending IosPubkyClient. watchOS should not start before that lands.

C. Concurrent SRS writers. SrsRepositoryImpl assumes it is the only writer to
/pub/loopky/srs/…: it reads a chunk, merges its dirty buffer, and puts the whole record back.
Two devices reviewing the same deck will silently clobber each other — the last flush wins and the
other device's grades vanish. Something has to give: per-card merge on read (compare reviewed_at,
keep the newer), a lease, or an explicit "the watch is authoritative while a session is running"
rule. This is a shared/ change and it is the one that can corrupt user data.

D. Standalone vs tethered. Decide before writing UI, because it decides A's size question too.
Standalone (watch talks to the homeserver directly over its own radio) is the better product and
costs the native lib; tethered (watch is a dumb terminal, phone does every Pubky call over the Data
Layer) is smaller and half the app but dies the moment the phone is in another room — which is the
exact scenario the whole feature exists for. Recommendation: standalone, arm64-only.

E. Cards, trimmed. dueForDeck pulls whole chunk records (CHUNK_SIZE ≈ 100 cards) to find
what is due, over a watch radio. And StudySessionUiState carries MediaRefs and a SpeakMatcher
phase that make no sense on a wrist. The watch should render text-only cards and skip cards
whose front or back is media-only rather than showing a broken tile — with the skip counted and
reported on the session-done screen, not hidden.

Shared prerequisites with #54 (loopky-cli)

#73 and #54 add a third and fourth consumer to shared/, and they hit the same things.
Worth doing once, deliberately, rather than twice under deadline:

  1. Platform-only dependencies have to leave commonMain. implementation(libs.kvault) sits in
    commonMain.dependencies (shared/build.gradle.kts) but is only used in
    AndroidSecureSessionStore and IosSecureSessionStore — the one commonMain reference is a doc
    comment in SecureSessionStore.kt. KVault 1.12.0 publishes android +
    iosArm64/iosSimulatorArm64/iosX64 and nothing else: no jvm, no watchos. So it fails to
    resolve for the CLI's jvm() target and for watchosArm64(), for the same one-line reason.
    Move it down into androidMain/iosMain. The other four commonMain deps are fine — coroutines,
    serialization and koin-core-viewmodel all publish JVM artifacts, and lifecycle-viewmodel
    publishes a desktop variant that resolves for a plain jvm() (matching is on platform-type
    attributes, not target name). Note watchOS still needs its own Keychain-backed
    SecureSessionStore actual: with no KVault artifact there, IosSecureSessionStore cannot simply
    be lifted into a shared appleMain.
  2. pubky-core-ffi-fork needs a per-platform build matrix. The CLI's desktop cdylibs
    (macOS-arm64/x86_64, linux-x86_64) and the watch's aarch64-apple-watchos slice are the same
    category of work in the same repo — build.sh/build_android.sh/build_ios.sh cover Android
    and iOS only. Whoever gets there first should build the matrix, not a one-off.
  3. Do not extract a core module yet — but know the trigger. The tempting move is splitting
    deck/card/SRS logic out so a CLI does not drag in presentation/. It buys less than it looks:
    Wear OS consumes the existing Android artifact untouched, watchOS is another target on the same
    module rather than a new module, and the repos-own-the-logic rule (no use-case layer) already
    keeps business logic free of UI. Against that, shared is still moving (Deck/card schema doesn't scale to Anki-sized decks: chunk records, slim the manifest, add provenance #43/Measure the homeserver per-record size limit — it should set CHUNK_SIZE #50/Chunk compaction: card deletes leave permanent holes #51 are open
    against the chunked layout), the ~680 tests live in one commonTest tree, SharedModule binds
    repos and VMs in one graph, and the exported iOS framework would have to re-export the new module.
    The seam is real, though, and these two issues name it: the CLI wants repos and treats VMs as dead
    weight, while the watch wants presentation/StudySessionViewModel(deckId = null) is
    already a watch session. So the split, when it comes, is core (domain + data) vs presentation
    (VMs). Trigger: the first out-of-tree consumer (loopky-cli: a headless Loopky client so AI agents can create and manage decks #54, open question 5). Cut it once, against a
    schema that has stopped moving, with two real consumers showing where the line goes.
  4. Durable unflushed writes. Loopky on the wrist: Wear OS + watchOS review-only clients #73 needs grades persisted the moment they are made — Wear OS
    ambient/doze kills the app between arm raises — rather than held in SrsRepositoryImpl's
    in-memory dirty set, and a CLI interrupted mid-import wants the same recovery story. This is the
    first requirement in either issue that genuinely argues for the local persistence layer v1
    deliberately skipped. Design it once, for both.

Suggested phasing

  1. Wear OS, tethered, read-only — prove the Data Layer session handoff and render "N due".
  2. Wear OS grading + local durable buffer + fix C.
  3. Wear OS standalone (native lib on the watch), settle the size question.
  4. watchOS, only after iOSApp.swift is live and the watchOS FFI slice builds.

Out of scope

Import, deck editing, publishing, Discover, tagging, profile. If a flow needs a keyboard or a
second column, it belongs on the phone.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions