You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
#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:
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.
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.
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 wantspresentation/ — 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.
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
Wear OS, tethered, read-only — prove the Data Layer session handoff and render "N due".
Wear OS grading + local durable buffer + fix C.
Wear OS standalone (native lib on the watch), settle the size question.
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.
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 interestingdecisions are all on the Loopky side: what
shared/has to grow, and how a second device gets asession and writes SRS state without fighting the phone.
Why a watch, specifically for SRS
StudySessionViewModelshowsone card, a reveal, and four grade buttons. That is the entire interaction surface.
deckId == nullalready means "everything due".StudySessionViewModel(deckId = null)→SrsRepository.dueToday()is exactly a watch session: no deck picker, no navigation, just thequeue. The VM a watch needs is written.
/pub/loopky/srs/{authorPubky}/{deckId}/{n}.jsonon your homeserver(
PubkyPaths.srsChunk), keyed by the deck's author, so a watch never needs write access toanyone else's tree — it needs the same capability set the phone already has.
SrsRepositoryImplbuffers grades in memory and flushes per chunk,with an automatic flush every
FLUSH_EVERY(20) reviews. That is the right shape for a devicewith a radio you do not want to wake per card.
What it should do
Four screens, total:
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 afaithful 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:
tethered" otherwise (see networking below).
raises. Unflushed grades must be persisted locally the moment they are made, not held only in
SrsRepositoryImpl's in-memory dirty set.Auth: there is no QR code on your wrist
The mobile flow (
IdentityRepositoryImpl.beginSignIn) starts an auth flow, appends Ringreturn-callbacks (
x-success=loopky://auth), and deeplinks into Pubky Ring. On a watch, all threesteps 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 (
awaitApprovalWithRetryexists for a reason).So the watch must not run its own Ring flow. It should receive a session from the paired phone:
DataClient/MessageClientover the Data Layer, phone → watch, one-shot on pairingand again on re-auth.
WCSession.transferUserInfo/updateApplicationContext.Session(identity,sessionSecret,capabilities,homeserver) througha watch
SecureSessionStoreactual — Keystore-backed on Wear OS, Keychain on watchOS. Neverwrite it to a
DataMapthat lingers unencrypted.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
wearAppmodule.shared/already hasandroidTarget(), so the repositories,StudySessionViewModel, and theKoin graph in
SharedModule.ktcome for free. This is by far the cheaper of the two platforms.minSdk = 29clears Wear OS 3 (API 30). No change needed.16M(arm64-v8a) and11M(armeabi-v7a) in
shared/src/androidMain/jniLibs/. A standalone watch APK has to ship one ofthose, 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 thewatch entirely and proxy every Pubky call over the Data Layer (which then forces the tethered
model — see D).
composeAppis a phone app that happens to be named generically; the watch app is a separatemodule, 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.ktsdeclaresiosArm64()+iosSimulatorArm64()only. watchOS needswatchosArm64()+watchosSimulatorArm64()added and theSharedframework exported for them.iosApp/iosApp/Frameworks/PubkyCore.xcframeworkcontainsios-arm64andios-arm64-simulatorand nothing else.
pubky-core-ffi-fork/build_ios.shhas to grow watchOS slices, which means thewhole Rust dependency tree (rustls included) must build for
aarch64-apple-watchos. This isthe risk item — assume it does not build first try.
ApkgReaderis anexpect objectwith no watch actual; it needs a stub (the watch never imports.apkgfiles) or the Anki code has to move behind a source-set boundary that watchOS omits.implementation(libs.kvault)is declared incommonMain, and 1.12.0 ships android + iosArm64/iosSimulatorArm64/iosX64 only, sowatchosArm64()will not resolvecommonMainuntil that dependency moves down intoandroidMain/iosMain. watchOS then needs its own Keychain-backedSecureSessionStoreactual —IosSecureSessionStorecannot be lifted into a sharedappleMain, because there is nothing tolift it onto. Same wall the CLI hits with
jvm(); see shared prerequisite 1 below.iOSApp.swifthas the Koin bootstrap commentedout pending
IosPubkyClient. watchOS should not start before that lands.C. Concurrent SRS writers.
SrsRepositoryImplassumes 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.
dueForDeckpulls whole chunk records (CHUNK_SIZE≈ 100 cards) to findwhat is due, over a watch radio. And
StudySessionUiStatecarriesMediaRefs and aSpeakMatcherphase 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:
commonMain.implementation(libs.kvault)sits incommonMain.dependencies(shared/build.gradle.kts) but is only used inAndroidSecureSessionStoreandIosSecureSessionStore— the onecommonMainreference is a doccomment in
SecureSessionStore.kt. KVault 1.12.0 publishes android +iosArm64/iosSimulatorArm64/iosX64 and nothing else: no
jvm, nowatchos. So it fails toresolve for the CLI's
jvm()target and forwatchosArm64(), for the same one-line reason.Move it down into
androidMain/iosMain. The other fourcommonMaindeps are fine — coroutines,serialization and
koin-core-viewmodelall publish JVM artifacts, andlifecycle-viewmodelpublishes a
desktopvariant that resolves for a plainjvm()(matching is on platform-typeattributes, not target name). Note watchOS still needs its own Keychain-backed
SecureSessionStoreactual: with no KVault artifact there,IosSecureSessionStorecannot simplybe lifted into a shared
appleMain.pubky-core-ffi-forkneeds a per-platform build matrix. The CLI's desktop cdylibs(macOS-arm64/x86_64, linux-x86_64) and the watch's
aarch64-apple-watchosslice are the samecategory of work in the same repo —
build.sh/build_android.sh/build_ios.shcover Androidand iOS only. Whoever gets there first should build the matrix, not a one-off.
coremodule yet — but know the trigger. The tempting move is splittingdeck/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,
sharedis 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 openagainst the chunked layout), the ~680 tests live in one
commonTesttree,SharedModulebindsrepos 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)isalready a watch session. So the split, when it comes, is
core(domain + data) vspresentation(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.
ambient/doze kills the app between arm raises — rather than held in
SrsRepositoryImpl'sin-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
iOSApp.swiftis 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.