Skip to main content

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 composeApp module, no shared+androidApp split. With Compose Multiplatform the UI itself lives in commonMain, so a separate Android shell module would be near-empty. iOS-readiness is enforced by discipline: all logic, DTOs, repositories, ViewModels and UI in commonMain; platform behavior behind interfaces/expect-actual (PDF sharing, token cipher, Koin platformModule).
  • 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 an iosApp/ 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 EncryptedSharedPreferences is deprecated (April 2025) with no drop-in successor, and the suggested DataStore+Tink stack is heavy machinery for two strings; on iOS the same TokenStorage interface will sit on KeychainSettings with a no-op cipher.
  • DTOs are hand-written, not OpenAPI-generated: codegen maps the backend's BigDecimal money fields to Double, which is exactly the precision bug the app's Money type (raw JSON literal preserved as a string) exists to prevent.
  • Payslip PDFs are generated on-device (android.graphics.pdf.PdfDocument behind a common PayslipPdfSharer interface). 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.yml workflow (pattern of docs.yml) runs ./gradlew build on PRs and pushes to main that touch multiplatform/**.

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; commonMain purity is enforced only by review, not by a compiling second target.
  • CodeQL does not scan Kotlin here — the analyze-java job builds only backend/. Adding a java-kotlin CodeQL job for multiplatform/ 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 /multiplatform but nothing aligns versions across projects.
  • A fourth toolchain (Android SDK) joins the contributor setup for anyone touching the app; multiplatform/README.md documents the SDK bootstrap.

References​