ADR-0018: Kotlin/Compose Multiplatform mobile app as an independent project
Status: Accepted Date: 2026-08-15
Context
Employees interact with the system for a handful of high-frequency,
self-service tasks — clocking in and out, checking a payslip, filing leave —
that suit a phone far better than a desktop SPA. The backend already exposes
everything such a client needs (/api/…/me endpoints, pure bearer-token auth,
no cookies, tenant resolution server-side from the user row), so a mobile app
is purely additive: no backend changes.
The stated direction is multiplatform: Android first, iOS later, sharing as much as possible. Per ADR-0002 every codebase in this repo is an independent build project.
Decision
A new top-level multiplatform/ directory holding a
Kotlin Multiplatform + Compose Multiplatform project with its own Gradle
wrapper — no coupling to the Maven or npm builds.
- Single
composeAppmodule, noshared+androidAppsplit. With Compose Multiplatform the UI itself lives incommonMain, so a separate Android shell module would be near-empty. iOS-readiness is enforced by discipline: all logic, DTOs, repositories, ViewModels and UI incommonMain; platform behavior behind interfaces/expect-actual(PDF sharing, token cipher, KoinplatformModule). - Only
androidTarget()is declared. iOS targets require macOS to link; declaring them would break Linux builds. Adding iOS later is a few lines in the build script plus aniosApp/Xcode wrapper and the platform actuals. - Stack: Kotlin 2.4.x, Compose Multiplatform 1.11.x, AGP 9.1, Gradle 9.6,
compileSdk/targetSdk 37, minSdk 26 (kotlinx-datetime maps to
java.time, API 26+, avoiding desugaring). Ktor 3 client (OkHttp engine on Android), kotlinx.serialization, JetBrains navigation-compose + lifecycle-viewmodel, Koin DI, multiplatform-settings for storage. - Tokens are stored encrypted at rest via a hand-rolled AES/GCM cipher
keyed in the Android Keystore. androidx
EncryptedSharedPreferencesis deprecated (April 2025) with no drop-in successor, and the suggested DataStore+Tink stack is heavy machinery for two strings; on iOS the sameTokenStorageinterface will sit onKeychainSettingswith a no-op cipher. - DTOs are hand-written, not OpenAPI-generated: codegen maps the backend's
BigDecimalmoney fields toDouble, which is exactly the precision bug the app'sMoneytype (raw JSON literal preserved as a string) exists to prevent. - Payslip PDFs are generated on-device (
android.graphics.pdf.PdfDocumentbehind a commonPayslipPdfSharerinterface). The backend has no payslip PDF endpoint — the web app renders client-side with jsPDF — and adding one was declined to keep this change frontend-only. - CI: a path-filtered
multiplatform.ymlworkflow (pattern ofdocs.yml) runs./gradlew buildon PRs and pushes to main that touchmultiplatform/**.
Consequences
Positive
- Ships an Android app with zero backend or SPA changes; the API contract is exercised by a second, independently-built client.
- Buildable entirely on Linux; contributors need no Apple hardware until the iOS target is actually added.
- The rest of ADR-0002's benefits apply: independent upgrades, independent CI.
Negative
- The iOS promise is untested until someone builds on a Mac;
commonMainpurity is enforced only by review, not by a compiling second target. - CodeQL does not scan Kotlin here — the
analyze-javajob builds onlybackend/. Adding ajava-kotlinCodeQL job formultiplatform/is a known follow-up (the ADR-0002 "silently unscanned" trap applies). - Dependency drift risk grows by one more independent project (now Maven, npm
and Gradle ecosystems side by side); Dependabot covers
/multiplatformbut nothing aligns versions across projects. - A fourth toolchain (Android SDK) joins the contributor setup for anyone
touching the app;
multiplatform/README.mddocuments the SDK bootstrap.
References
- ADR-0002 — the independence rule this follows
- ADR-0013 — why the client sends no tenant identifier
../../multiplatform/README.md— build & run instructions../../.github/workflows/multiplatform.yml— CI gate