A multiplatform flashcards app for self-testing across subjects. The goal is a shared product across Android, iOS, web, and a backend, sharing business logic via Kotlin Multiplatform (KMP) while keeping a native UI per platform.
You can build decks of cards (term + definition, with an optional front image and a category), practice them three ways (classic flip, text-entry Test, Multiple Choice) with a live in-session streak and an end-of-session per-card review, discuss individual cards on the shared catalog, and sync across devices through the backend. The Android and iOS apps are offline-first; the web app is a thin client over the same API. You can also browse and practice the public deck catalog as a guest, with no account.
| Area | State |
|---|---|
KMP monorepo — :shared:api (Room-free API contract) + :shared (offline-first Room data layer + iOS framework); build-logic convention plugins |
✅ |
| Ktor + Postgres backend — auth, decks, sessions, home feed, images, streaks, discussions, RBAC | ✅ |
| Android app — Compose, offline-first, full feature set | ✅ |
| Web app — React/TypeScript, full feature set + admin UI | ✅ |
| iOS app — SwiftUI, near-parity (everything below except the admin UI) | ✅ |
| Email/password + Google sign-in (all clients) | ✅ |
| Practice — classic flip, text-entry Test, Multiple Choice (all clients) | ✅ |
| In-session answer streak (live "N in a row") + daily practice streak | ✅ |
| End-of-session per-card review (correct/incorrect recap with your answers) | ✅ |
| Guest mode — browse + practice the public catalog without an account | ✅ |
| Card discussions on global decks — threaded, opt-in per deck, moderated (report / lock / delete) | ✅ |
| Answer suggestions ("this should be correct" on global-deck cards) with admin review | ✅ |
| Flashcard images (front side) — uploaded to S3, served via CloudFront | ✅ |
| Role-based access control — roles + feature permissions; manage the global catalog, user roles & moderation (web + operator CLI) | ✅ |
| JWT access + refresh tokens, transparent refresh-on-401, server-side logout | ✅ |
A single Gradle build with four modules (:shared + its :shared:api child, :androidApp,
:backend) plus a build-logic included build, and a separate web app:
Flashcards/
├── shared/ KMP library — the offline-first data layer (domain, Room-KMP database,
│ │ repositories, sync) + the Kotlin/Native iOS framework. Used by androidApp & iosApp.
│ └── api/ :shared:api — the @Serializable API DTOs + Ktor FlashcardApiClient + token
│ refresh. Room-free; the backend depends on only this, and :shared re-exports it.
├── build-logic/ Gradle convention plugins (SDK levels + JVM/Kotlin baseline), applied by each module.
├── androidApp/ Android app (Jetpack Compose, MVVM, Hilt). Offline-first.
├── iosApp/ SwiftUI app consuming the Shared framework (XcodeGen project, not in the Gradle build).
├── backend/ Ktor server (Netty) + Exposed + Postgres. The API the apps sync against.
├── webApp/ React + TypeScript + Vite SPA (its own npm toolchain, not in the Gradle build).
├── docker-compose.yml Local Postgres for the backend
└── gradle/libs.versions.toml Version catalog (single source for deps)
The HTTP API contract (DTOs + FlashcardApiClient + token refresh) lives in :shared:api, so
the backend can consume it without dragging in the mobile Room/SQLite layer. The offline-first
data layer (domain, Room-KMP database, repositories, sync) lives in :shared and is reused by both
apps — iOS gets a one-call createIosFlashcardSdk(...) factory and links it all as a single
Shared.framework (which re-exports :shared:api).
- Language: Kotlin 2.2.10 (JVM toolchain 17), Kotlin Multiplatform
- Android: Jetpack Compose, Hilt (DI), Room-KMP (offline cache), Ktor client (OkHttp), DataStore, Coil (images), Credential Manager (Google sign-in)
- iOS: SwiftUI, the shared
SharedKotlin/Native framework (Room-KMP offline cache, Darwin Ktor engine), Keychain token storage; XcodeGen-generated project - Backend: Ktor 3.2.x, Exposed + HikariCP, PostgreSQL, BCrypt, kotlinx.serialization, AWS SDK for Kotlin (S3), a profanity filter for discussion moderation
- Web: React 19 + TypeScript + Vite
- Shared:
:shared:api(kotlinx.serialization DTOs + a KtorFlashcardApiClient+ token refresh) and:shared(the offline-first Room data layer) reused across platforms - Build: Gradle with
build-logicconvention plugins + a version catalog
- JDK 17+ and the Android SDK (easiest via Android Studio).
Create
local.propertieswithsdk.dir=/path/to/Android/sdk(gitignored) if Studio doesn't. - Docker for Postgres and the backend's Testcontainers tests — via
Colima (
brew install colima docker docker-compose) or Docker Desktop. - Node 20.19+ (or 22.12+) and npm, for the web app.
- (Optional) an AWS account for image uploads and a Google Cloud OAuth client for Google sign-in. The app runs fully without either — see Optional features.
git clone <your-fork-url> Flashcards && cd Flashcards
# 1. Backend + Postgres
docker compose up -d postgres # Postgres on :5432 (see notes below)
./gradlew :backend:run # serves at http://0.0.0.0:8080
# 2. Android app (in another terminal) — boot an emulator first
./gradlew :androidApp:installDebug
# 3. Web app (in another terminal)
cd webApp
npm install
cp .env.example .env # then edit if needed
npm run dev # http://localhost:5173Register a new account in either client, or use the seeded demo login
(demo@flashcards.dev / demo, seeded as an admin). The backend ships a seeded global
deck catalog (Flags of the World, National Capitals, U.S. State Capitals,
World Currencies).
Container runtime: the commands below use
docker compose. With Colima, runcolima startfirst; with Docker Desktop, just make sure it's running. If Colima's daemon isn't found, export the socket:export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock".
Shortcut: a root
Makefilewraps the common dev loop —make start(Postgres + backend),make stop,make restart,make logs,make status, andmake web(the web dev server).
docker compose up -d postgres # Postgres on :5432
./gradlew :backend:run # serves at http://0.0.0.0:8080Use a different host port and point the backend at it:
POSTGRES_PORT=5433 docker compose up -d postgres
DB_JDBC_URL=jdbc:postgresql://localhost:5433/flashcards ./gradlew :backend:runThe server is ready when it logs Responding at http://0.0.0.0:8080. On first boot it
creates its schema and seeds a demo user (demo@flashcards.dev / demo, granted the admin
role), a fixed dev refresh token (demo-token), the RBAC catalog (roles + permissions), and
the global deck catalog (Flags of the World, National Capitals, U.S. State Capitals, World
Currencies — the geography decks tagged "Geography").
Auth uses short-lived access JWTs plus an opaque refresh token, so protected routes
need an access token (a bare demo-token won't authenticate). Exchange the seeded refresh
token for one, then call a protected route:
ACCESS=$(curl -s http://localhost:8080/auth/refresh \
-H 'Content-Type: application/json' -d '{"refreshToken":"demo-token"}' | jq -r .accessToken)
curl -s "http://localhost:8080/decks" -H "Authorization: Bearer $ACCESS"
# -> the first page of decks, including the seeded Flags of the World deck(Or POST /auth/login with the demo credentials to get an accessToken the same way.)
docker compose down # stop Postgres, keep data
docker compose down -v # stop + wipe data (fresh seed next run)
colima stop # stop the VM (Colima only)The app's base URL is http://10.0.2.2:8080 (BuildConfig.BACKEND_BASE_URL). 10.0.2.2
is the Android emulator's alias for your host machine, so an emulator reaches a
locally-running backend with no extra setup.
- Start the backend (above).
- Boot an emulator and install the app:
…or just Run the
./gradlew :androidApp:installDebug
androidAppconfiguration in Android Studio. - Register or log in. (Google sign-in appears only if you've configured an OAuth client — see below; email/password always works.)
Physical device: 10.0.2.2 is emulator-only. Point BACKEND_BASE_URL (in
androidApp/build.gradle.kts) at your host's LAN IP (e.g. http://192.168.x.x:8080), put
the device on the same network, and add that host to
androidApp/src/main/res/xml/network_security_config.xml (it only allows cleartext to dev hosts).
The Xcode project is generated from iosApp/project.yml by XcodeGen —
Flashcards.xcodeproj is not committed. Requires Xcode and brew install xcodegen.
cd iosApp
xcodegen generate # writes Flashcards.xcodeproj from project.yml
open Flashcards.xcodeproj # then Run the Flashcards scheme on a simulatorOr build/run headlessly:
xcodebuild -project iosApp/Flashcards.xcodeproj -scheme Flashcards \
-destination 'platform=iOS Simulator,name=iPhone 17' buildThe app links the shared KMP code as a Kotlin/Native framework: a "Compile Kotlin Framework"
build phase runs ./gradlew :shared:embedAndSignAppleFrameworkForXcode (driven by Xcode's
config/SDK/arch env vars) before Swift compiles, so Swift can import Shared. The first build is
slower while Gradle compiles the framework. The build phase needs a JDK 17+ on PATH/JAVA_HOME
(it falls back to /usr/libexec/java_home).
The backend URL is the per-config build setting BACKEND_BASE_URL in project.yml (default
http://localhost:8080, which the simulator uses to reach a backend on your Mac). For a
physical device, set the Debug BACKEND_BASE_URL to your host's LAN IP (e.g.
http://192.168.x.x:8080), re-run xcodegen generate, and add an App Transport Security
exception for that host (cleartext HTTP).
Edit the project (targets, build settings, the shared-framework build phase) in
project.ymland re-runxcodegen generate— never hand-edit the.xcodeproj. Xcode must have a simulator runtime matching its SDK; if a build reports "No simulator runtime version … available", runxcodebuild -downloadPlatform iOS. A physical-device build additionally needs the iOS device platform installed and a signing team.
cd webApp
npm install
cp .env.example .env # set VITE_API_BASE_URL / VITE_GOOGLE_WEB_CLIENT_ID if needed
npm run dev # http://localhost:5173The web app calls the backend at VITE_API_BASE_URL (default http://localhost:8080),
which is allowed by the backend's CORS config out of the box. It covers the same core flows as
the mobile apps — auth, the deck library (title + category search, sort), deck create/edit, all
three practice modes (routed at /decks/:id/practice) with the streak + end-of-session review,
card discussions, and guest browsing — and is the only client with the admin surfaces:
managing the global deck catalog (/library/global), assigning user roles (/admin/users), and
the content-moderation queues. Admin screens are gated on the user's permissions and redirect
otherwise.
The dev server is pinned to port 5173 (
vite.config.ts,strictPort) because that's the origin registered with the Google OAuth client; if 5173 is taken it fails loudly rather than drifting to another port (which Google would reject withorigin_mismatch).
The app is fully usable without these. They're opt-in and require resources in your own cloud accounts — never reuse another deployment's identifiers.
Without a client ID, the Google button is hidden (Android, web & iOS) and the backend's
POST /auth/google returns 503; email/password sign-in works regardless.
- In Google Cloud Console, create an
OAuth 2.0 Web application client ID. Add
http://localhost:5173to its authorized JavaScript origins (for the web app). - Provide the Web client ID to:
- Android build + backend: add it to your user-global
~/.gradle/gradle.properties(see Configuration). - Web app: set
VITE_GOOGLE_WEB_CLIENT_IDinwebApp/.env.
- Android build + backend: add it to your user-global
- iOS (optional): the Google Sign-In SDK on iOS issues an ID token whose audience is an
iOS OAuth client ID, not the Web one. Create an OAuth 2.0 iOS client ID (bundle id
com.rrbrambley.flashcards), then:- Copy
iosApp/Local.xcconfig.exampletoiosApp/Local.xcconfig(gitignored) and setGOOGLE_IOS_CLIENT_ID(the client ID) andGOOGLE_REVERSED_CLIENT_ID(its reversed form,com.googleusercontent.apps.<…>, used as the OAuth callback URL scheme); re-runxcodegen generate. - Set
GOOGLE_IOS_CLIENT_IDon the backend too (in~/.gradle/gradle.propertiesfor:backend:run, or as an env var) — it accepts both the Web and iOS client IDs as valid token audiences.
- Copy
The backend verifies Google ID tokens against the configured client ID(s) — the Web ID (web + Android) and the iOS ID (iOS) — so they must match between each client and the backend.
Without S3/CloudFront configured, POST /images returns 503 and the image picker is
effectively disabled; everything else works. To enable it:
- Create a private S3 bucket in your AWS account.
- Put a CloudFront distribution in front of it (use an Origin Access Control so the
bucket can stay private). Note the distribution domain →
CDN_BASE_URL. - Create an IAM user/role with
s3:PutObjectonarn:aws:s3:::<bucket>/images/*, and make its credentials available via the default AWS chain —AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYenv vars or~/.aws/credentials. Never put AWS secrets in the repo. - Set
S3_BUCKET,S3_REGION, andCDN_BASE_URLfor the backend (see Configuration).
Uploads go through the backend (client → POST /images → backend validates type/size →
S3 PutObject → returns the CloudFront URL), so no browser CORS config is needed on the bucket.
Accepted types: JPEG, PNG, WebP, GIF; max 5 MB.
The curated profile avatars (FLA-162) are served from the same CDN under an avatars/ prefix
(${CDN_BASE_URL}/avatars/<key>.png) — source PNGs live in assets/avatars/, deployed with
make avatars. They degrade gracefully: with no CDN configured the avatar catalog is empty and
clients fall back to an initials monogram.
Personal/account-specific values are not committed. Set them in your user-global
~/.gradle/gradle.properties (Gradle reads it transparently for the Android build and
:backend:run) or as environment variables. The web app reads its own webApp/.env.
Example ~/.gradle/gradle.properties:
GOOGLE_WEB_CLIENT_ID=<your-oauth-web-client-id>.apps.googleusercontent.com
S3_BUCKET=<your-bucket-name>
S3_REGION=<your-bucket-region>
CDN_BASE_URL=https://<your-distribution>.cloudfront.net| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP port |
DB_JDBC_URL |
jdbc:postgresql://localhost:5432/flashcards |
Postgres JDBC URL |
DB_USER / DB_PASSWORD |
flashcards / flashcards |
Postgres credentials |
DB_MAX_POOL_SIZE |
5 |
Hikari pool size |
CORS_ALLOWED_ORIGINS |
http://localhost:5173 |
Comma-separated browser origins |
JWT_SECRET |
(insecure dev default) | HMAC256 signing key for access JWTs — must be overridden in production |
JWT_ISSUER / JWT_AUDIENCE |
flashcards-backend / flashcards-clients |
Access-token issuer / audience claims |
JWT_ACCESS_TTL_SECONDS |
900 |
Access-token lifetime (15 min) |
JWT_REFRESH_TTL_SECONDS |
2592000 |
Refresh-token lifetime (30 days) |
RATE_LIMIT_AUTH_LIMIT |
20 |
Max /auth/* requests per IP per window |
RATE_LIMIT_AUTH_WINDOW_SECONDS |
60 |
Rate-limit window for /auth/* |
GOOGLE_WEB_CLIENT_ID |
(unset → Google disabled) | OAuth Web client ID (web + Android token audience) |
GOOGLE_IOS_CLIENT_ID |
(unset) | OAuth iOS client ID — additional accepted token audience for iOS sign-in |
S3_BUCKET / S3_REGION / CDN_BASE_URL |
(unset → uploads disabled) | Image storage |
S3_ENDPOINT |
(unset → real AWS) | Override for a local S3-compatible server (e.g. MinIO) |
AWS credentials are not in this table — the backend reads them only from the default AWS chain.
:backend:runalso forwards these keys fromgradle.propertiesif present.
| Variable | Default | Purpose |
|---|---|---|
VITE_API_BASE_URL |
http://localhost:8080 |
Backend base URL |
VITE_GOOGLE_WEB_CLIENT_ID |
(blank → button hidden) | OAuth Web client ID |
BACKEND_BASE_URL is set in androidApp/build.gradle.kts (http://10.0.2.2:8080 for the
emulator). GOOGLE_WEB_CLIENT_ID is read from Gradle properties into BuildConfig; blank
disables Google sign-in.
BACKEND_BASE_URL is a per-config build setting in iosApp/project.yml (surfaced through
Info.plist, read via AppConfig). The simulator reaches http://localhost:8080; for a device
build, point BACKEND_BASE_URL at your host's LAN IP — the Info.plist includes an
NSAllowsLocalNetworking App Transport Security exception so the dev cleartext backend works (a
production build should use HTTPS and drop it). GOOGLE_IOS_CLIENT_ID / GOOGLE_REVERSED_CLIENT_ID
come from the gitignored iosApp/Local.xcconfig (template: Local.xcconfig.example) — blank/absent
hides the Google button; fill them in (and re-run xcodegen generate) to enable it. See
Sign in with Google.
The app icon, launch screen (LaunchLogo / LaunchBackground in Assets.xcassets), display name,
and accent color are configured via project.yml's info: block + the asset catalog. The bundled
app icon is a placeholder — replace Assets.xcassets/AppIcon.appiconset/icon-1024.png with final
brand art before release. Distribution (TestFlight/App Store) needs a real signing team: set
DEVELOPMENT_TEAM in project.yml and archive the Flashcards scheme (Release).
Endpoints require an access-token JWT as a bearer except the public routes — the auth
entrypoints (/auth/register, /auth/login, /auth/google, /auth/refresh), the read-only
guest catalog (/catalog/*), and reading a card's discussion (GET /discussions/{cardUid}/messages).
When an access token expires the client transparently calls /auth/refresh and retries. Bodies are
the @Serializable DTOs in shared/api/src/commonMain/.../api/ (the discussion + admin DTOs are
backend-only, mirrored by the web app).
| Method + Path | Purpose |
|---|---|
POST /auth/register, POST /auth/login |
Issue an access JWT + a refresh token |
POST /auth/google |
Exchange a Google ID token for access + refresh tokens (requires GOOGLE_WEB_CLIENT_ID) |
POST /auth/refresh, POST /auth/logout |
Rotate a refresh token / revoke it server-side (end the session) |
GET /auth/me, PATCH /auth/me |
The caller's identity, roles + permissions / set the display name |
GET /decks, GET /decks/{id} |
List (cursor-paginated: ?limit&cursor) / fetch decks (a user's decks + the global catalog) |
POST /decks, PUT /decks/{id}, DELETE /decks/{id} |
Create / update / delete a deck (owner-scoped; global decks writable only by an admin) |
GET /decks/global, POST /decks/global |
List / create global (ownerless) catalog decks — admin only (manage_global_decks) |
GET /catalog, GET /catalog/{id} |
Public — browse the global deck catalog without an account (guest mode) |
POST /sessions |
Start or resume a practice session for a deck (in a given mode) |
GET /sessions?active=, GET /sessions/{id} |
List (cursor-paginated) / fetch practice sessions |
PATCH /sessions/{id}, POST /sessions/{id}/complete |
Update progress / complete a session |
POST /sessions/{id}/answers, GET /sessions/{id}/answers |
Append to / read the per-card answer log (drives the streak + end-of-session review) |
GET /streaks |
The caller's daily practice streak (overall + per deck) |
GET /home |
Server-computed home feed |
POST /images |
Upload a flashcard image; returns its CloudFront URL (requires S3 config) |
GET /discussions/{cardUid}/messages (public), POST /discussions/{cardUid}/messages |
Read (paginated) / post a card discussion message (posting is auth-gated) |
POST /discussions/messages/{id}/report |
Report a discussion message |
PATCH /discussions/{cardUid}/lock, PATCH /discussions/decks/{deckId}/discussion |
Lock a thread / toggle discussions for a deck — admin only (manage_discussions) |
POST /cards/{cardUid}/answer-suggestions |
Suggest a typed answer as also-correct on a global-deck card |
GET /admin/users, GET /admin/roles |
List users (paginated + ?q= email search) / the role catalog — admin only (manage_roles) |
POST /admin/users/{id}/roles, DELETE /admin/users/{id}/roles/{key} |
Grant / revoke a user's role — admin only |
GET /admin/discussions/reports, DELETE /admin/discussions/messages/{id} |
The reports queue / moderator-delete a message — admin only |
GET /admin/answer-suggestions, POST /admin/answer-suggestions/{id}/accept (or …/dismiss) |
Review queue / accept (adds the alternative answer) or dismiss a suggestion — admin only |
The
/auth/*routes are rate-limited per client IP (429on exceed); discussion posting is additionally rate-limited per user per thread. Login/register/google and/auth/mecarry the user'spermissionsso clients can gate admin UI. List endpoints return aPage<T>envelope ({ items, nextCursor }); passnextCursorback ascursorfor the next page. Admin routes (/admin/*,/decks/global, discussion lock/toggle) require a feature permission and return403otherwise. Discussion content is plaintext-only, profanity-filtered, and auto-locks past a size threshold.
./gradlew :androidApp:assembleDebug # build the Android APK
./gradlew :androidApp:testDebugUnitTest # Android unit tests
./gradlew :androidApp:connectedDebugAndroidTest # instrumented tests, incl. the Room migration test (needs a device/emulator)
./gradlew :shared:build # build :shared (+ :shared:api) for android/iOS/JVM
./gradlew :shared:jvmTest :shared:api:jvmTest # shared commonTest on the JVM host
./gradlew :backend:build # build the backend
./gradlew ktlintCheck # Kotlin lint across all modules
./gradlew :backend:test # backend integration tests (Testcontainers)
cd webApp && npm run build && npm run lint && npm run test # build + lint + test the web appCI (.github/workflows/ci.yml) runs ktlint, the JVM unit tests (backend/android/shared) with
coverage, and the web build/lint/tests on every relevant PR (jobs are path-gated so each runs
only when its area changed). Two expensive native jobs — Android instrumented (emulator;
covers the Room migration test) and iOS (a macOS runner: shared iOS tests + an xcodebuild
of the app) — are opt-in: they run only when their paths changed and the PR carries a label
(ci:android / ci:ios / ci:native), or via a manual workflow dispatch.
The backend tests start a real Postgres via Testcontainers, so they need Docker. Under Colima they additionally need:
export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"
export DOCKER_API_VERSION=1.44 # Colima's engine requires API >= 1.44
./gradlew :backend:testA one-time ~/.testcontainers.properties with docker.host=unix://$HOME/.colima/default/docker.sock
also works.
Found a bug or have an idea? Open a
GitHub issue and pick the Bug or
Feature / improvement template. Because this is a KMP monorepo, note which surfaces are affected
(backend, :shared, web, Android, iOS) — larger multi-surface features are usually tracked as an
epic issue with a per-platform checklist. Roadmap and known work live in
Issues.
See CONTRIBUTING.md for conventions and the pre-PR checklist, and SECURITY.md for how secrets are handled and how to report a vulnerability (please don't file security issues as public GitHub issues).
MIT © Rob Brambley