A mobile flashcards app that fuses Duolingo TinyCards' playfulness, Anki's spaced repetition, and Pubky's decentralized identity and social graph. iOS + Android, built with Kotlin Multiplatform.
There is no Loopky account and no Loopky server. You sign in with a key held by Pubky Ring, and your decks and study progress are written to a Pubky homeserver you hold the key to.
- Architecture:
docs/Architecture.md— module layout, layering, homeserver data model, Pubky/Nexus integration, what is still open. Start here. - Import spec:
docs/specs.md— the paste-to-import flow and the parser rules the test suite is written against. - Privacy:
PRIVACY.md— what reaches a homeserver, and whose it is.
Captured against a live Pubky homeserver on both platforms. Full-resolution files live in
screenshots/ and double as the store asset sets.
Captured on the v0.6.0 debug build — a Pixel phone emulator (1080×2400) and the Pixel_Tablet
emulator in landscape (2560×1600), the two width classes the adaptive layouts target.
Home, deck detail and onboarding are real two-pane layouts at expanded width, with a navigation rail in place of the tab bar — not a stretched phone column.
Settings and the recovery-phrase screens are
FLAG_SECURE, so they cannot be screenshotted — that is deliberate, and why they are absent here.
Captured on the Release build (production Nexus + Homegate) against a live homeserver, on the iPhone 17 Pro Max simulator (1320×2868) and the iPad Pro 13-inch simulator (2064×2752) — the two sizes App Store Connect asks for. The SwiftUI screens are native, not Compose Multiplatform, and the iPad has its own regular-size-class layouts rather than a stretched phone column.
Android is feature-built end to end. Onboarding and Pubky Ring sign-in, homeserver signup
(SMS / Lightning / invite code), the daily study queue, the SRS study loop with Listen / Speak /
Type, deck library and editor, paste import and bulk file import (.txt / .csv / .apkg),
publishing, discovery and tag browse, profiles and follows, and settings.
iOS is at feature parity. Browse without an account, then sign in with Pubky Ring — by
deeplink, or by scanning a QR from the phone that holds your key — or create an account on a
homeserver (SMS, Lightning or invite code), restore one from a recovery phrase or file, and back a
key up three ways. Then the daily study queue, the SRS study loop with Listen / Speak / Type and
reverse cards, the deck library and editor, paste import and bulk file import
(.txt / .csv / .apkg), publishing, discovery and tag browse, profiles and follows, and
settings including the synced study intervals. Driven against a real homeserver on the iPhone 17
simulator; see the iOS sections of journeys/RESULTS.md, which also
record what could not be reached there and why.
iPad and every width but a phone is #173.
Roughly 680 shared tests run on every PR. Two known blockers before a Play release: the native Pubky libraries fail 16 KB page-size alignment, and release packaging is still an unsigned universal APK with no R8.
Business logic is shared; UI is native per platform. That is the one rule to internalize.
shared/— KMP module holding domain models, repositories (which own the business logic — there is no use-case layer), and the ViewModels both platforms consume. Platform glue (Pubky FFI, TTS, speech recognition, background work) is eitherexpect/actualor a Koin-bound interface.composeApp/— the Android app. Jetpack Compose screens, Navigation Compose, Koin. Android-only despite the name; Compose Multiplatform UI is not used for iOS.iosApp/— the iOS app. SwiftUI screens,NavigationStack, Koin bootstrap.
Pubky is reached through one interface, PubkyClient, over the UniFFI bindings generated by
pubky-core-ffi-fork and checked in. Published decks live on the author's homeserver as a manifest
plus chunked card records; the homeserver is the source of truth and the app is not offline-first.
Global questions a single homeserver cannot answer — trending tags, search, "who else uses Loopky"
— go to the Pubky Nexus indexer.
loopky/
├── shared/
│ └── src/
│ ├── commonMain/kotlin/com/github/jvsena42/loopky/
│ │ ├── domain/ # models (pure Kotlin, no framework imports)
│ │ ├── data/ # repositories, PubkyClient, Nexus, storage
│ │ └── presentation/ # ViewModels (StateFlow + SharedFlow)
│ ├── commonTest/ # the whole automated suite
│ ├── androidMain/ # Pubky FFI, TTS, speech, WorkManager, Koin
│ └── iosMain/ # Pubky adapter, TTS, speech, BGTaskScheduler, Koin
│
├── composeApp/src/androidMain/kotlin/com/github/jvsena42/loopky/
│ ├── ui/ # Compose screens + navigation
│ ├── LoopkyApp.kt # Application; starts Koin
│ └── MainActivity.kt
│
├── iosApp/iosApp/
│ ├── Views/ # SwiftUI screens
│ ├── Navigation/ # NavigationStack
│ ├── DI/ # Koin bootstrap + Flow→SwiftUI bridge
│ ├── Pubky/ # IosPubkyClient + generated bindings
│ └── iOSApp.swift
│
└── journeys/ # scripted end-to-end journeys + results
| Concern | Choice |
|---|---|
| UI (Android) | Jetpack Compose + Material 3 Expressive |
| UI (iOS) | SwiftUI + NavigationStack |
| Shared logic | Kotlin Multiplatform (commonMain) |
| DI | Koin |
| Async | Coroutines + Flow; hand-rolled Swift bridge (IosFlowWatcher / FlowObserver) |
| Persistence | Pubky homeserver + in-memory session cache — no local database |
| Secrets | Liftric KVault → Android Keystore / iOS Keychain |
| Identity / social | Pubky (pubky-core-ffi-fork, UniFFI) + Nexus indexer for global reads |
| Navigation | Per-platform native |
| Lint | detekt (Kotlin) · SwiftLint (Swift) |
Android minSdk 29, targetSdk 36.
./gradlew :composeApp:assembleDebugOr use the run configuration from your IDE's toolbar.
Open /iosApp in Xcode and run. shared is consumed as a static framework.
./gradlew :shared:allTests # shared KMP tests (~680)
./gradlew :shared:compileKotlinMetadata # fast commonMain compile check
./gradlew detektAll # Kotlin lint (add --auto-correct to fix formatting)
./gradlew lintSwift # Swift lint (needs `brew install swiftlint`)CI runs detekt, the unit tests, and an Android debug build on every PR.
End-to-end coverage is manual and scripted: journeys/ holds 19 numbered journeys
driven on a device with android-cli, with results and dates in
journeys/RESULTS.md. A green build says nothing about what the screen
renders.
Learn more about Kotlin Multiplatform.































