A standalone, offline-first app. It puts the location-engineering, offline-first and
multi-module architecture I care about into one place you can actually run.
Every screen still runs on deterministic mock data by default — a real Kotlin/Ktor backend now
exists too, sharing :contract DTOs with the client, off by default behind a flag.
Highlights · Screenshots · Features · Architecture · Getting started · Roadmap
Portfolio: cv-siddharth.vercel.app · Sibling project: PaymentsLab · Shared libraries: kmp-toolkit · Shared build logic: kmp-build-logic
Table of contents
At a glance — 46-module clean architecture: 36 local (13 feature · 12 core) + 10 composed via
includeBuild(external/kmp-toolkit), Room schema v48, 368 host-rendered Roborazzi screenshots (JVM, no emulator). Numbers auto-generated fromsettings.gradle.ktsbyscripts/gen-readme.sh.
Mileway is a self-contained, offline-first mileage tracker. The whole thing still runs in airplane
mode: you track trips, log expenses, route approvals, and the data is there after a restart, reads
come from Room, and writes queue in a durable offline outbox. A real Kotlin/Ktor backend now exists
alongside it (:server + a shared :contract module) as an opt-in addon, not a replacement — it's
off by default (NetworkBackendFlags.useRealBackend = false), so the demo behavior above is
unchanged unless the flag is flipped.
I also use it as a reference for how I build Android and KMP apps. That means Compose Multiplatform,
a multi-module clean architecture, MVI-style unidirectional state, Koin for
DI, Room (KMP) with DataStore, and a gms/noGms flavor split so the same code ships to both the
Play Store and F-Droid.
Mileway doesn't stand alone. Its Gradle convention plugins live in a separate, reusable repo —
kmp-build-logic, pulled in as a Gradle
includeBuild — so the AGP/Kotlin/Compose/test setup isn't copy-pasted per project but shared across
my KMP work. Its shared libraries increasingly come from the same place too:
kmp-toolkit, a 36-module MIT Kotlin
Multiplatform toolkit vendored here as a git submodule. Mileway consumes ten of its modules —
:mvi-core, :result, :common, :location, :offline-outbox, :security, :app-shell,
:network, :settings and the on-device :ai seam (multimodal + streaming) — rather than
hand-rolling them, the "extract the reusable core the moment a second app needs it, then consume it"
philosophy in practice: Mileway is both the flagship and a consumer. Its sibling,
PaymentsLab, goes deep on the payments/UPI
slice the same way this repo goes deep on location and offline-first. All three sit under the same
portfolio.
- 🛰️ Real location engineering. The tracking pipeline fights GPS jitter and recovers from spikes, with spike detection, four-bucket distance accounting, IMU fusion, device-tier-adaptive sampling and config-driven detection thresholds — and a deterministic recompute that re-derives history from persisted points when the math changes.
- 📴 Offline-first, backend optional. Room + DataStore stay the base data source for every screen; writes queue in a durable offline outbox and flush once online. It still runs fully in airplane mode either way.
- 🔗 Kotlin/Ktor backend, opt-in (V33). A real
:servermodule (Ktor + Exposed) speaks the same:contractDTOs as the client, with idempotent location/event ingestion (opIddedup against a unique DB index) and the identicalPolicyRateEnginecomputing reimbursement amounts on both sides. Off by default behindNetworkBackendFlags.useRealBackend— flipping it swaps the data source, not the domain logic. - 🧩 Multi-module clean architecture. Feature modules never touch each other. They meet only at the
:appcomposition root, wired through Koin. - 🌍 Kotlin Multiplatform — shared UI layer, narrower iOS shell. The screens themselves live in
commonMain: all thirteen:feature:*modules and every:core:*module bar the Android-only:core:maps-krossmapcompile foriosArm64/iosSimulatorArm64— that's real compile parity, not an Android app with a port pending. What differs today is the navigation surface: iOS rendersMilewayApp(), a four-tab shell (Home · Track · Spends · Travel, plus a What's New overlay), while the full thirteen-graph JetBrains Compose Navigation host lives in:appand is Android-only. So approvals, payables, cards, payments, events, advances, media, agent and profile compile for iOS but have no iOS entry point yet — the shell's remaining callbacks (onOpenAccount,onOpenMap,onAddExpense,onExpenseHistory, …) are no-ops there. Background scheduling uses kmpworkmanager (BGTask dispatcher + AppDelegate); platform services sit behindexpect/actual. - 🔀 One codebase, two distributions. A
gmsPlay build and a FOSSnoGms/ F-Droid build, with a dependency-prefix guard that fails the build the moment a proprietary library leaks into FOSS. - 🧪 Quality gates in CI. A full Roborazzi/host-rendered screenshot suite on the JVM (no emulator, no network), Napier structured logging, detekt, ktlint and Kover, plus reproducible F-Droid release workflows.
- 🔥 Ember theme, four platforms from one KMP core. A warm amber/red dark theme (replacing an
earlier phosphor-green look) skins Android/iOS phone, Wear OS, watchOS and Compose Desktop — all
from the same
commonMainarchitecture. - 📄 On-device document intelligence. A capture-to-form pipeline combines on-device AI, text recognition and heuristics — OCR field-fill, doc-type classification and duplicate detection — on device where the platform supports it, degrading gracefully everywhere else.
- 🤖 On-device LLM assistant. The expense chat runs against a real on-device model behind a
shared
LlmGateway— ML Kit GenAI on Android, Apple Foundation Models on iOS via a Swift bridge (xcodebuild-gated, not device-verified) — degrading to the offline retrieval engine wherever no model is available. Not a stub response generator.
Every frame renders from deterministic mock data, recorded with Roborazzi on the JVM — no emulator, no device. The journeys below are stitched from those host-rendered frames into animated flows with
ffmpeg(scripts/build-flow-gifs.sh), so the showcase is everything moving in synergy, not a wall of stills. Regenerate the frames with./gradlew :app:screenshotTestNoGmsDebug.
📱 Phone — 11 animated end-to-end journeys (click to expand)
The full still catalogue — every screen plus component matrices (status cards, booking cards,
PO cards, success-state variants, theme pickers) rendered from @Preview composables by
ScreenshotCatalogTest — lives in
docs/screenshots/. Full screens are recorded by
ScreenshotGalleryTest (phone) / WearScreenshotGalleryTest (watch) / a JVM
desktopTest (desktop).
⌚ Beyond the phone — Wear OS · watchOS · Live Activity · Widgets · Compose Desktop (click to expand)
One shared Kotlin Multiplatform core, rendered on every target — all host-side with Roborazzi /
SwiftUI ImageRenderer, no watch, launcher or windowing system required.
The watch app shares commonMain's SurfaceSnapshot/WearPresentation mapping with the phone,
skinned with the same Ember accent via WearMilewayTheme (androidx.wear.compose.material3 — its
own design system, never the phone/iOS CMP theming module).
| Dashboard | Recent trips |
|---|---|
![]() |
![]() |
Native SwiftUI over the :sharedWatch KMP framework — today/week distance, a red live-tracking
pill and a trips drill-down.
![]() |
An ActivityKit Live Activity (Lock Screen banner) plus a Dynamic Island expanded presentation for an
in-progress trip, driven by the phone's TrackingLiveActivityController.
| Lock Screen banner | Dynamic Island (expanded) |
|---|---|
![]() |
![]() |
Android home-screen widget (Glance) and iOS WidgetKit (home-screen + Lock Screen), both over
the same shared SurfaceSnapshot — today/week distance with a live "Tracking now" indicator and an
interactive App-Intent Start/Stop button on iOS.
| Android Glance | iOS home | iOS Lock Screen |
|---|---|---|
![]() |
![]() |
![]() |
A curated gallery of the app's signature surfaces, composed from the shared core:ui component
library and core:data models — no mockups, no Android/iOS emulator: every image below is
host-rendered JVM-side with Compose Multiplatform's runDesktopComposeUiTest. The shipped
:desktopApp binary is narrower than the gallery: main() opens a single window rendering the
dashboard over mock SurfaceSnapshot/trip data, with initKoin(listOf(coreUiModule)) and no
repository or ViewModel graph behind it. Desktop is a design-system preview target here, not a
fully-wired app target like Android or iOS.
| Dashboard | Live tracking | Trip history |
|---|---|---|
![]() |
![]() |
| Trip detail | Log expense | Approvals |
|---|---|---|
![]() |
![]() |
![]() |
| Profile |
|---|
![]() |
Every feature is fully interactive on mocked, offline data.
| Area | What's inside |
|---|---|
| Tracking | Live GPS trip tracking on a foreground service (jitter suppression, spike detection, four-bucket accounting, device-tier-adaptive sampling, config-driven abnormal detection); geofenced check-in with manual fallback; saved tracks (journey/submission tabs); trip insights; hardware-events log; multi-frame odometer OCR with typed start/end snapshots; multi-session restore of interrupted trips; a success screen with a policy-driven reimbursement amount; GPX / CSV / KML / GeoJSON import & export plus Excel export. |
| Logging & Expenses | Step-by-step manual trip logging backed by a durable Room submit-outbox (survives kill/relaunch); a 2-step add-expense wizard with entry-context linking (trip / card / advance / event / scanner), concurrent bulk submit and multi-currency; a shared policy rate engine for mileage reimbursement. |
| Travel | Travel hub, active-trip card (flight / train), upcoming bookings, plus trip & booking history surfaces. |
| Approvals & Payables | Approval queue with policy-violation badges; persistent clarification rooms with lifecycle/metadata/history and rich chat + attachments, shared across every transaction type via a common detail scaffold with comments, audit trail and action flags; payables hub, multi-step create-PR / invoice flows and history surfaces. |
| Payments, Events & Cards | QR pay / request + history; event creation, history and rich event detail; card home / detail / request with KYC, QR, dispute and limits flows. |
| Profile & Account (super-profile, V24) | Account hub, advance requests, Canvas-rendered analytics dashboards, an AI assistant sheet, notification centre, permission-health screen, MaterialKolor theme engine; plus V24 depth: verification centre + corporate-email/OTP verification, growth surfaces (referral, coupons, scratch rewards, campaigns), membership (Mileway Club, subscription plans, incentive programs), account-deletion lifecycle, enriched active-sessions, act-on-behalf session delegation with an app-wide "Acting as" banner, external wallet linking via OTP, payout identity (masked bank + editable UPI handle + QR), and a manager/reportee tracking view. |
| Customization / personas (V24) | A single plugin registry is the composition mechanism — every feature (tile, capability, tunable value) gates through it, resolved by layering FORCED > USER > PRESET > DEFAULT. A Master Plugin page toggles any of them live with source chips; persona presets (Corporate Commuter, Super-App Consumer, Gig Driver, Minimal Guest) reshape the whole app — different hubs, auth flows, tracking behavior and tunable knobs — from one account. Tracking settings (accuracy/interval/displacement floors, force-GPS, sync toggles) are registry-backed and persisted, driving the live location engine. |
| Backend & sync (V33, opt-in) | A Kotlin/Ktor :server module (Netty + Exposed, H2 by default) sharing :contract DTOs with the client; idempotent location/event ingestion (opId dedup on a unique index) and a PolicyRateEngine shared verbatim between server and client; a JourneyValidator/DistanceValidator validation layer; writes queue through a durable offline outbox and flush once online. Off by default (NetworkBackendFlags.useRealBackend = false) — the on-device :stub path is unchanged. |
| Local dev & infra | A local analytics sink with a kill switch, a Ktor network-log + API-tester debug console, and a server-driven tracking config loaded from local JSON — all offline by default. |
| Media & document intelligence | Unified capture (camera/gallery, CameraX flash/pinch-zoom/tap-focus, VNDocumentCamera scanner on iOS) behind one contract used by every call site; on-device document-intelligence pipeline — OCR field-fill, doc-type classification and duplicate detection, combining on-device AI, text recognition and heuristics and degrading gracefully where a model isn't available; QR / barcode scanning; real watermark burn-in; attachment grid. |
| Dynamic forms | A 16-field-type form engine driving expense/claim entry — validation, conditional visibility, GST auto-calc, and AI field suggestions from the document-intelligence pipeline. |
| Master search | A registry-based search that fans a query across five providers spanning every feature module. |
| Analytics | Filterable, trend-aware Canvas dashboards with export and a leaderboard view. |
Multi-module clean architecture. Feature modules never depend on one another; they meet only at the
:app composition root. State is unidirectional. Each screen exposes a single immutable state as a
StateFlow, collected with collectAsStateWithLifecycle, and a shared ScreenState wrapper models
the loading, empty, error and content cases.
graph TD
APP[":app · composition root · navigation · Koin graph"]
subgraph Features
direction LR
FT["tracking"]; FL["logging"]; FM["media"]; FP["profile"]
FA["approvals"]; FPA["payables"]; FTR["travel"]; FAG["agent"]
FC["cards"]; FPM["payments"]; FE["events"]; FAD["advances"]
FWN["whatsnew"]
end
subgraph Core
direction LR
UI["core:ui · design system + theme engine"]
DATA["core:data · Room (KMP) · DataStore"]
NET["core:network · API contracts"]
PLAT["core:platform · expect/actual services"]
SEC["core:security · root detection"]
MAPS["core:maps (+ krossmap / maplibre)"]
end
STUB[":stub · deterministic mock data"]
WEAR[":wear · Wear OS app"]
SWATCH[":sharedWatch · headless watchOS framework"]
WIDGET[":widget · Glance home-screen widget"]
CONTRACT[":contract · shared DTOs + PolicyRateEngine"]
SERVER[":server · Ktor + Exposed backend (opt-in)"]
APP --> Features
APP --> STUB
APP --> WEAR
APP --> WIDGET
SWATCH --> DATA
Features --> Core
STUB --> DATA
STUB --> NET
NET --> CONTRACT
SERVER --> CONTRACT
Key patterns
- commonMain-first KMP. Core modules compile for Android and iOS (
iosArm64,iosSimulatorArm64). Platform-bound tech (FusedLocation, CameraX, ML Kit, WorkManager, BiometricPrompt, the foreground service) sits behindexpect/actualinterfaces in:core:platform. - Koin DI. One module per feature, and the
InitKoin()bootstrap is re-entrancy-safe for both the AndroidApplicationand the iOS entry point. - SearchProvider registry. Each feature binds a
SearchProviderinto Koin. The master-search aggregator resolvesgetAll<SearchProvider>()and fans out, so search and the features stay decoupled. - Shared scaffolds.
FormSubmissionScaffoldandHistoryListScaffoldstandardise the create and history flows that travel, payables, payments and events all reuse. - Navigation. Type-safe JetBrains Compose Navigation, with per-feature graphs assembled at
:app. - Opt-in backend, shared contract.
:serverand the client both depend on:contractfor request/response DTOs and thePolicyRateEngine, so the wire format and the reimbursement math can't drift between them.NetworkBackendFlags.useRealBackend(defaultfalse) is the single switch between:stub's mock data and the real Ktor calls; writes queue through a durable offline outbox either way.
The interesting part of a portfolio project isn't the feature list — it's the trade-offs. A few choices here were deliberate, and each one closed off an easier alternative on purpose.
| Decision | Why | What it cost / trade-off |
|---|---|---|
commonMain-first KMP, platform tech behind expect/actual |
Business logic, state and UI written once and proven to compile for Android, iOS, Wear, watchOS and Desktop — not "Android code we might port later." The expect/actual seam is the discipline that keeps android.*/java.* from leaking into shared code. |
You write to the intersection of platforms. Anything platform-bound (FusedLocation, CameraX, ML Kit, BiometricPrompt, WorkManager, the foreground service) needs a declared interface + an actual per target, which is more ceremony than a plain Android call. |
| Offline-first as the base layer, real backend as an opt-in addon | Room + DataStore stay the source of truth for every screen and the whole screenshot/test suite stays reproducible on the JVM with no live dependency — that was never going to change. What did change: a real Kotlin/Ktor :server now exists, sharing :contract DTOs with the client so the wire format can't drift, with the identical PolicyRateEngine computing reimbursement amounts on both sides. It's off by default (NetworkBackendFlags.useRealBackend = false), which is what "repositories already look network-shaped" was building toward — wiring the real API turned out to be a flag flip plus routes, not a rewrite. |
The flag being off by default means most day-to-day usage still exercises mock data, not the live path; idempotent sync (opId dedup) and the offline-outbox flush are covered by dedicated Ktor/JVM tests rather than the full screenshot suite. Auth has since landed — JWT bearer tokens, /api/auth/login + /refresh open and every other /api/* route inside one authenticate("jwt") block, with an AuthTokenStore/AuthApi client seam and Ktor's bearer{} refresh-on-401 — but it's only ever been exercised by tests: with useRealBackend off and no runtime opt-in wired up yet, no running client has hit an authenticated route. |
| Location engine: four-bucket accounting + deterministic recompute | GPS is dirty. Rather than throw away suspect fixes, every point is kept and classified into original / cleaned / abnormal / mock buckets, all persisted. Distance can then be recomputed from the stored points, so a later math fix re-derives history instead of stranding already-tracked trips on old numbers. |
More storage and a more complex write path than "sum the deltas as they arrive." The payoff is auditability and forward-fixable math — the thing that actually matters when a user disputes a distance. |
| MVI + single immutable state per screen | One StateFlow<State> per screen, collected with collectAsStateWithLifecycle, wrapped in a shared ScreenState that models loading/empty/error/content uniformly. Renders are a pure function of state; there's no half-updated UI to reason about. |
More boilerplate than mutable view state, and every field change means a fresh copy of the state object. Accepted because it makes recomposition predictable and screens trivial to screenshot-test. |
SearchProvider registry instead of a central search index |
Each feature binds its own SearchProvider into Koin; the master-search aggregator resolves getAll<SearchProvider>() and fans out. Adding a searchable feature is a one-line Koin binding — no edit to a shared switch statement, no feature-to-feature dependency. |
Search is only as good as each provider, and cross-feature ranking is naive (per-provider, then merged). Fine for the scale here; the decoupling is worth more than global relevance tuning. |
One codebase, two distributions (gms / noGms) with a FOSS purity guard |
The same app ships to Play (Google Maps/Firebase via gms) and to F-Droid (MapLibre + offline MBTiles via noGms). A dependency-prefix guard fails the build when an unlisted proprietary prefix reaches the noGms classpath, and dependencyGuard baselines that classpath so any new arrival is a failing diff. Honest status: noGms is not GMS-free today. Its baseline carries 19 proprietary entries — 15 com.google.android.gms/com.google.mlkit plus 4 transitive com.google.firebase — because ML Kit powers OCR and document scanning and is deliberately allowlisted. So the guard means "nothing new leaked in", not "this build is FOSS", and F-Droid submission is blocked until that set reaches zero. |
Every platform integration needs a FOSS fallback (maps being the big one), and CI has to build/verify both flavors. The guard is what makes "it's really FOSS" a checkable claim rather than a README promise. |
Shared Gradle logic in a separate includeBuild repo |
The convention plugins live in kmp-build-logic, not inlined here, so AGP/Kotlin/Compose/test config is reused across projects (PaymentsLab too) instead of drifting per-repo. | One more repo to keep in sync, and a composite build to reason about. Worth it the moment a second KMP project exists. |
| Autonomous Ralph-loop development with a revert-on-uncommitted guard | The app is built through versioned .ralph/PLAN_Vxx.md phases, each iteration editing → building → committing in one turn. A Stop hook reverts uncommitted tracked edits between turns, which forces small, self-contained, individually-revertable commits. |
The workflow is unforgiving — a build that fails to commit in-turn is lost. That constraint is the point: it makes every change atomic and the history clean to bisect. |
| Module | Responsibility |
|---|---|
:app |
Composition root, navigation host, Koin graph assembly, build flavors |
:core:ui |
Compose design system, theme engine (MaterialKolor), Canvas charts, shared scaffolds |
:core:data |
Room (KMP) database, DAOs, entities, DataStore repositories |
:core:network |
API contract & policy models (mocked) |
:core:platform |
expect/actual platform-service interfaces + Android/iOS impls |
:core:security |
Device-integrity (root) detection, encryption-ready storage |
:core:maps · -krossmap · -maplibre |
Map-surface contract + flavor-specific implementations |
:core:common |
Shared utilities / primitives |
:core:media |
Unified capture contract (camera/gallery) + launcher, watermarking, odometer OCR orchestration |
:core:ai |
On-device document-intelligence pipeline — OCR field-fill, doc-type classification, duplicate detection, degrading gracefully without a model |
:core:forms |
Dynamic form engine — 16 field types, validation, conditional visibility, GST auto-calc |
:contract |
Shared request/response DTOs and PolicyRateEngine, depended on by both :server and the client (:core:network) so the wire format and reimbursement math can't drift between them |
:server |
Kotlin/Ktor backend (Netty + Exposed, H2 by default) — miles/location/event ingestion with idempotent opId dedup, plus JWT auth (/api/auth/login + /refresh) guarding every other route; opt-in, off by default in the client |
:feature:* |
tracking · logging · media · profile · approvals · payables · travel · agent · cards · advances (petty-cash + QR wallets) · payments · events · whatsnew (bundled release-notes catalog, list/detail + hero carousel, engagement recorder) |
:stub |
Deterministic mock data for every repository; the default data source while NetworkBackendFlags.useRealBackend is off |
:wear |
Wear OS app — dashboard, trip list/detail, tile, complication, ongoing activity, phone sync |
:sharedWatch |
Headless KMP static framework (no Compose) consumed by the native SwiftUI watchOS app |
:shared |
iOS umbrella framework — re-exports core:ui, feature:tracking, feature:agent and feature:logging as the single Mileway.framework Xcode links against |
:widget |
Glance home-screen widget (mileage summary + quick start/stop) |
:desktopApp |
Compose Desktop preview — main() opens one window rendering a mock-data dashboard over core:ui; Koin is listOf(coreUiModule) only, so no repository/ViewModel graph. The wider desktop gallery above is host-rendered from desktopTest. |
:app-web-preview |
wasmJs browser shell embedded in the portfolio site — compiles core:ui's theme package straight from source (allowlisted srcDir) and rebuilds a curated dashboard / tracking / expense demo over in-memory fakes, since Room KMP publishes no wasm target |
:baselineprofile |
Macrobenchmark module generating the Baseline Profile via :app:generateNoGmsReleaseBaselineProfile |
build-logic |
Gradle convention plugins (centralised AGP / Kotlin / Compose config) |
Mileway/
├── app/ # Android application: composition root, navigation, DI, flavors
├── core/
│ ├── ui/ # Compose design system, theme engine, shared scaffolds
│ ├── data/ # Room (KMP) + DataStore
│ ├── network/ # API contracts (mocked)
│ ├── platform/ # expect/actual platform services
│ ├── security/ # root detection, encryption-ready storage
│ ├── maps/ maps-krossmap/ maps-maplibre/ # map-surface contract + impls
│ ├── common/ # shared utilities
│ ├── media/ # unified capture contract + launcher, watermarking, OCR orchestration
│ ├── ai/ # on-device document-intelligence pipeline
│ └── forms/ # dynamic form engine (16 field types, validation, GST)
├── contract/ # shared DTOs + PolicyRateEngine (client + server)
├── server/ # Kotlin/Ktor backend (Netty + Exposed) — opt-in, off by default
├── feature/ # tracking · logging · media · profile · approvals · payables
│ # travel · agent · cards · advances · payments · events · whatsnew
├── stub/ # deterministic mock data for every repository
├── wear/ # Wear OS app (dashboard, trip list/detail, tile, complication)
├── sharedWatch/ # headless KMP framework for the native SwiftUI watchOS app
├── shared/ # iOS umbrella framework (re-exports core:ui, feature:tracking, feature:agent, feature:logging)
├── widget/ # Glance home-screen widget + quick start/stop
├── desktopApp/ # Compose Desktop preview — single mock-data dashboard window
├── app-web-preview/ # wasmJs browser preview shell (theme + curated demo screens)
├── baselineprofile/ # macrobenchmark module for Baseline Profile generation
├── build-logic/ # Gradle convention plugins
├── docs/ # README assets, screenshots, release & brand docs
└── fastlane/ # store metadata + screenshots
| Layer | Technology |
|---|---|
| Language | Kotlin 2.4.20-Beta1 |
| UI | Compose Multiplatform 1.12.0-beta02, Material 3 |
| Build | AGP 9.4.0-alpha04, Gradle 9.7.0-milestone-3, KSP 2.3.10, Gradle Kotlin DSL, convention plugins, version catalog |
| DI | Koin 4.2.2 (multiplatform) |
| Database | Room 2.8.4 (KMP, bundled SQLite) |
| Settings / session | AndroidX DataStore |
| Networking | Ktor 3.5.1 client (OkHttp + Darwin engines) + Ktor server (Netty + Exposed, H2 by default) — the real backend is opt-in behind NetworkBackendFlags.useRealBackend (default false); :stub is the default data source |
| Concurrency | Coroutines + Flow (no LiveData); kotlinx-datetime 0.8.0 in commonMain |
| Navigation | compose-nav-graph-annotations 0.2.1 |
| Maps | osmdroid / MapLibre (noGms, offline MBTiles) · KrossMap (gms) |
| Charts | Canvas-only (no MPAndroidChart / Vico) |
| Theming | MaterialKolor 5.0.0 |
| Capture | Peekaboo (KMP camera/gallery) |
| On-device AI | ML Kit GenAI (Android) / Apple Foundation Models (iOS, Swift-bridge) behind a shared LlmGateway, text recognition + barcode scanning, degrading to an offline heuristic engine where a model isn't available |
| Testing | JUnit, MockK, Turbine, Robolectric, Koin-Test, Roborazzi 1.68.0 screenshots |
| Quality | detekt 2.0.0-alpha.5, ktlint, Kover, dependency-guard |
| SDK | compileSdk 37, minSdk 30, JDK 21 |
Prerequisites: JDK 21+ (CI builds on 25), the Android SDK (API 35), and — for iOS/watchOS
targets — a Mac with Xcode 16+. Point Gradle at your SDK via local.properties
(sdk.dir=/path/to/Android/sdk) or the ANDROID_HOME env var.
Clone with submodules. The Gradle convention plugins (kmp-build-logic) and the shared
libraries (kmp-toolkit) live under external/ as git submodules, pulled into the build with
includeBuild. A plain clone leaves them empty and the build fails with "Included build
'external/kmp-build-logic' does not exist" — so recurse:
git clone --recurse-submodules https://github.com/darkpandawarrior/Mileway.git
cd Mileway
# already cloned without --recurse-submodules? pull them in:
git submodule update --init --recursive
# Assemble the offline-safe default build
./gradlew assembleNoGmsDebug
# Install on a device/emulator (API 30+)
adb install app/build/outputs/apk/noGms/debug/app-noGms-debug.apkNo network connection is required. The data is all mock and persists locally through Room and
DataStore. A real Kotlin/Ktor backend ships too but is off by default — see
Build flavors and the :server notes below to opt in.
Offline check: enable airplane mode, track a trip, kill and relaunch the app, and confirm the record persisted.
All build & tooling commands
# Build variants
./gradlew assembleNoGmsDebug # FOSS / offline build (default)
./gradlew assembleGmsDebug # Google-services build
./gradlew assembleNoGmsRelease # reproducible FOSS release (F-Droid)
# Tests & screenshots (noGms only; gms crashes Robolectric)
./gradlew testNoGmsDebugUnitTest # JVM unit tests (370 test classes, no emulator)
./gradlew recordRoborazziNoGmsDebug # (re)record screenshot baselines → docs/screenshots/
# Quality
./gradlew ktlintCheck detekt # style + static analysis
./gradlew :app:koverXmlReportNoGmsDebugCoverage # coverage report (the name quality.yml uses;
# bare :app:koverXmlReport is not a real task here)
# Backend (opt-in — off by default)
./gradlew :server:run # start the Ktor server on :8080 (H2 in-memory)
./gradlew :server:test # server route / auth / opId-dedup tests
# then set NetworkBackendFlags.useRealBackend = true and point BaseUrlProvider at the server
# (Android emulator: http://10.0.2.2:8080). Auth: POST /api/auth/login with the seeded demo user.
# Or run it containerised against real Postgres instead of in-memory H2:
./gradlew :server:installDist # the image packages a built distribution, it doesn't compile
JWT_SECRET=dev-secret docker compose up --build # server on :8080 + postgres:16
# JWT_SECRET is required by design — compose aborts rather than silently using the dev default.
# Other targets
./gradlew :desktopApp:assemble # Compose Desktop (single mock-data dashboard window)
./gradlew :wear:assembleNoGmsDebug # Wear OS
./gradlew :shared:compileKotlinIosSimulatorArm64 # iOS (compile check; run via Xcode/iosApp)A maps flavor dimension splits the app into a proprietary and a FOSS build:
| Flavor | Maps | Google / Play / Firebase | Use case |
|---|---|---|---|
gms |
KrossMap (Google Maps / MapKit) | Firebase + Play services | Play Store build |
noGms |
MapLibre + offline MBTiles (no API key) | no Firebase; ML Kit still present (see below) | fully offline; F-Droid pending |
A dependency-prefix guard fails the build if an unlisted proprietary prefix reaches the noGms
classpath, and dependencyGuard baselines that classpath so a new arrival shows up as a failing diff.
Both are real, but neither makes the build FOSS today: ML Kit (OCR + document scanning) is explicitly allowlisted, and the baseline currently holds 19 proprietary entries. Check it yourself:
grep -cE 'play-services|com\.google\.mlkit' app/dependencies/noGmsReleaseRuntimeClasspath.txt # 15
grep -cE 'play-services|com\.google\.mlkit|firebase' app/dependencies/noGmsReleaseRuntimeClasspath.txt # 19Getting that to zero means replacing ML Kit in the FOSS flavor — see
docs/OWNER_ACTIONS.md § Tier 6 for the decision that blocks F-Droid.
This repo is built and evolved almost entirely through autonomous Ralph-loop iteration
(.ralph/PLAN.md plus versioned plans PLAN_V3 … PLAN_V20, each one migration phase — KMP
hoisting, iOS parity, the AI assistant rebuild, etc.). Progress is tracked per iteration in
.ralph/PROGRESS.md.
- Verification gate (current, flavored build):
(the
./gradlew assembleNoGmsDebug && ./gradlew testNoGmsDebugUnitTestgmsflavor crashes Robolectric, so unit tests only run onnoGms.) - Guardrail: the Ralph Stop hook reverts uncommitted tracked edits between turns — each iteration must edit, build/test, and commit within the same turn, or the change is lost.
- Historical note: earlier plan revisions reference the original, pre-flavor bootstrap commands
assembleDebug/testDebugUnitTestfrom when this repo was a bare single-variant extraction of the mileage feature; those tasks are long complete and the flavored commands above are what CI and current Ralph runs actually use.
2,510 @Test methods across 370 test classes in 30 modules, plus 159 host-rendered screenshots.
Numbers you can reproduce:
grep -rho '@Test' --include='*.kt' . | wc -l # 2510
ls docs/screenshots/*.png | wc -l # 159| Layer | What runs it | Gates a merge? |
|---|---|---|
JVM unit tests (testNoGmsDebugUnitTest) |
quality.yml |
✅ required check |
KMP commonTest on the host (testAndroidHostTest) |
quality.yml |
✅ required check |
| ktlint · detekt · Kover · dependencyGuard (both flavors) | quality.yml |
✅ required check |
Backend (:server:test) |
ci.yml → Build & Test |
✅ required check |
Instrumented / Room migrations (GMD, pixel6Api34) |
ci.yml |
✅ fails on real test failures |
| Roborazzi screenshots | screenshots.yml |
records + proposes a diff |
| iOS · watchOS frameworks | ios.yml (macOS runner) |
compile-checked |
| wasmJs portfolio preview | ci.yml |
compile-checked |
What is deliberately not claimed. The instrumented suite runs on a Gradle Managed Device and
tolerates emulator-boot failure (shared runners fail to boot regularly) — but it distinguishes that
from a real assertion failure by inspecting the JUnit XML, so a genuinely failing test does fail the
job. Roborazzi covers the screens with committed baselines, not literally every screen. There is no
iosTest source set anywhere: Kotlin/Native rejects backtick test names containing ,/(/), which
the existing commonTest suites use in 39 places, so iOS-only logic is moved down to commonMain to be
testable instead (see GeoPointMapping.kt).
Backend (:server). Idempotent opId dedup, the shared PolicyRateEngine, and route behaviour
against in-memory H2 — a plain kotlin("jvm") module, outside the KMP/Android build, so it has its
own CI step.
Supply-chain guards. dependencyGuard baselines both release classpaths, so a transitive
version bump or a new artifact is a failing diff rather than a surprise. The noGms baseline is the
one that matters for FOSS claims — see Build flavors for what it currently contains.
Distribution. Play and F-Droid (release.yml, publish-fdroid.yml) plus Amazon, Huawei, Samsung,
Indus and Aptoide workflows. All are gated on repo secrets and inert until configured — see
docs/OWNER_ACTIONS.md. GitHub Releases also make the app trackable via
Obtainium with no extra config. Uptodown has no public
submission API — manual web-form upload only.
A snapshot of where Mileway is and where it's heading. This is a portfolio/demo project, so the roadmap reflects direction rather than commitments.
Shipped
- Offline-first app on deterministic mock data (no backend calls by default; see V33 below)
- Multi-module clean architecture with Koin DI
- Compose Multiplatform UI;
commonMaincore compiles for Android + iOS -
gms/noGmsflavor split + FOSS dependency-prefix guard - Room (KMP) + DataStore persistence
- Location engine (jitter / spike / four-bucket / IMU fusion) with a simulated drive source
- Master search: a registry across feature modules with an aggregator, results screen and navigation
- Roborazzi/host-rendered screenshot suite (JVM-only, no emulator), detekt / ktlint / Kover, CI + release workflows
- Wear OS companion tile
- [~] iOS UI parity (V19) — partially shipped: code parity done, shell parity not. Every
feature screen lives in
commonMainand compiles for iOS; background scheduling via kmpworkmanager; AppDelegate + BGTask dispatcher; iOS builds and passes all CI gates. The iOS app shell is still a subset:ContentViewhostsMilewayApp(), a four-tab scaffold (Home · Track · Spends · Travel + a What's New overlay), because the JetBrains Compose Navigation graph that reaches the other nine feature modules lives in:appand is Android-only. Standing up that navigation surface on iOS is open work. - Napier structured logging across all modules
- AI assistant / "agent" feature (V20). Offline, retrieval-grounded chat over real local
trip/expense/card data; Room-backed persistent history + 5-minute session resume; on-device
voice I/O (STT/TTS); feedback, export and real-usage popular-question ranking; full
commonMain+ iOS parity. (A dedicated Popular/Unanswered analytics screen and persisted unanswered-question submission are still open — tracked as backlog.) - On-device LLM backing (post-V25).
LlmGatewayswaps the assistant onto a real on-device model — ML Kit GenAI on Android, Apple Foundation Models on iOS (Swift bridge,xcodebuild-gated, not yet device-verified) — degrading to the offline retrieval engine wherever no model is available. - Matrix / terminal design-language pass across the whole UI (theme tokens, topbar, screenshots)
- Renamed the project and package from MileTracker(Demo) to Mileway end-to-end
- Multi-account depth (V22). Room-backed multi-persona account store with a real switch-account mechanism, PIN/biometric gate, and per-account session isolation (trip/expense queries re-scoped, cross-persona cold-start reconciliation).
- Profile / Settings depth (V22). Room-backed approval delegation, a full Active Sessions screen (per-device revoke), a real local Notification Centre with unread counts and channel toggles, connected-account integration toggles, real permission-state checks, and a local support-ticket flow (My Tickets) with video tutorials.
- Login / onboarding depth (V22). Staged sign-in loading states, a demo-mode persona picker, an app-wide local PIN gate (set/check with biometric fallback), and a welcome disclaimer sheet requesting real location/notification permissions before first use.
- Watch platform build-out (V23). Shared
SurfaceSnapshot/WatchSyncPayloaddomain contract incore:data; a full Wear OS app (dashboard, trip list/detail, tile, complication, ongoing activity, phone→watchDataClientsync); a native SwiftUI watchOS app over the new headless:sharedWatchKMP framework with two-wayWatchConnectivitysync; Android Glance widget + App Shortcuts + Quick Settings tile + AppFunctions; iOS WidgetKit widgets, Live Activity/Dynamic Island, and App Intents/Siri Shortcuts; an accessibility sweep across every new surface on both platforms. - Feature-parity & tracking-engine depth wave. A policy-driven reimbursement rate engine and a tracking success screen; device-tier-adaptive sampling, config-driven abnormal detection, multi-frame odometer OCR with typed snapshots (Room migration to v18) and deterministic distance recompute; GPX/CSV/KML/GeoJSON import + Excel export; a durable Room submit-outbox for manual logging; offline sync scaffolding (local-data flagging + multi-session restore); and local-only dev infra — an analytics sink with kill switch, a Ktor network-log/API-tester console, and a server-driven tracking config loaded from local JSON. All offline/mock, no backend.
- Super-profile & plugin-composition platform (V24). A single plugin registry as the app's composition mechanism (TILE / CAPABILITY / VALUE plugins resolved by layering FORCED > USER > PRESET > DEFAULT), a live Master Plugin page with source chips, and four persona presets that reshape the whole app from one account. On top of it: auth depth (phone login, MFA, OTP-via-call), signup onboarding + what's-new, profile depth (OTP phone change, email/corporate verification, avatar, saved places, emergency contacts), a verification centre + card KYC, growth (referral, coupons, scratch rewards, campaigns), membership (Mileway Club, subscription plans, incentive programs), account-deletion lifecycle + enriched sessions, act-on-behalf session delegation (app-wide "Acting as" banner, trip-ownership isolation), external wallet linking via OTP, payout identity (masked bank + editable UPI handle + QR), and registry-backed tracking-settings persistence (accuracy/interval/displacement floors, force-GPS, mileage-sync toggles) wired into the live location engine, plus a manager/reportee tracking view, a vehicle garage & rates, destination mode, an ecometer, an engagement/trust hub, a unified priority banner system, and a reorganized super-profile hub.
- On-device intelligence & feature-parity series (V25→V32). Three new foundation modules —
core:media(unified capture),core:ai(document intelligence) andcore:forms(dynamic forms) — under an on-device OCR pipeline that combines ML Kit GenAI / text-recognition / heuristics on Android and Foundation Models / Vision on iOS (field-fill + doc-type + duplicate detection, degrading gracefully). On top: one capture launcher (camera / gallery / files / PDF / document-scanner / QR-barcode + watermark) that all seven legacy call sites converge on; a 16-type dynamic form engine with GST auto-calc and AI field-suggestions; a 2-step add-expense wizard with entry-context linking, concurrent bulk-submit and multi-currency; a shared transaction-detail scaffold with persistent clarification rooms (lifecycle, metadata, history, rich chat + attachments), comments and audit flags; feature depth across search, analytics, events, cards and home; a shell-nav fix, shake-to-report and a storage-management screen; and Room schema v39 → v47 across explicit migrations. - Kotlin/Ktor backend, opt-in (V33). A real
:servermodule (Ktor + Exposed, H2 by default) alongside a shared:contractmodule for request/response DTOs and thePolicyRateEngine, so server and client can't drift on wire format or reimbursement math; idempotent location/event ingestion (opIddedup against a unique DB index); aJourneyValidator/DistanceValidatorvalidation layer; writes queued through a durable offline outbox and flushed once online. Off by default behindNetworkBackendFlags.useRealBackend— offline-first (Room + DataStore,:stub) stays the base, the backend is an addon, not a replacement. - JWT auth on
:server+ client (V34).configureAuth()installs Ktor'sjwt("jwt")provider;/api/auth/loginand/api/auth/refreshare the only open routes and every other/api/*route sits inside oneauthenticate("jwt")block. Client side: anAuthTokenStore(access token in memory, ~30d refresh token in the toolkit's encrypted settings — Keychain / EncryptedSharedPreferences), anAuthApi, andwithBearerAuth()layering Ktor'sbearer{}provider on for attach-and-refresh-once-on-401. Covered by:server'sAuthRoutesTestandcore:network'sAuthTokenStoreTest/BearerAuthFlowTest— but not yet exercised by a running client:NetworkBackendFlags.useRealBackendis stillfalseand nothing flips it at runtime, so no app build has actually called an authenticated route. - iOS launch-crash fix (V33).
CADisableMinimumFrameDurationOnPhoneadded toInfo.plist(a Compose MultiplatformPlistSanityCheckrequirement) — the iOS app builds (xcodebuild-green) and launches correctly on device.
Exploring
- Baseline Profiles real on-device generation (device-gated; static profile ships today)
- Instrumented (on-device) UI test tier alongside the JVM suite
- Larger bundled offline map packs
- Expand Roborazzi catalog to remaining edge-case states
- watchOS live device verification, AppFunctions ADB invocation, Siri phrase invocation — all compile/build-verified here, pending real-device/simulator-runtime confirmation
- Live iOS-simulator app-content rendering — currently blocked by an upstream Compose
Multiplatform/Skiko ↔ Xcode 26 / iOS 26 simulator Metal issue (works on physical devices); the
shared CMP UI is represented in this README by the Android catalog and the Compose Desktop
gallery instead. iOS itself is
xcodebuild-green and device-verified for launch. - A runtime opt-in for the real backend. JWT auth is implemented and tested on both sides, but
NetworkBackendFlags.useRealBackendis a compile-timefalsewith no debug toggle wiring it on, so the authenticated path has never run in an actual app process — that wiring, plus the remaining PLAN_V33.1 routes beyond miles/location/events, is the next step
- iOS. Every
:core:*module compiles to an iOS framework, withexpect/actualservices backed by CoreLocation, Vision (OCR), UserNotifications, LocalAuthentication and BackgroundTasks. A few proprietary integrations (in-app update, install-referrer) are stubbed withTODO(ios)markers, and the shared Compose UI renders through a minimal SwiftUI host. Home/Lock Screen WidgetKit widgets, a Live Activity/Dynamic Island for active tracking, and App Intents/Siri Shortcuts (start/stop/log) round out the iOS surface, all reading the same offlineSurfaceSnapshot/WatchFacadeseam as the phone app. - Wear OS.
:wearis a full Compose-for-Wear-OS app: aScalingLazyColumndashboard (today/week distance, tracking state, goal progress), trip list + detail, a real tile and complication backed by the shared snapshot, an ongoing activity wired to live tracking, and a phone→watchDataClientsync (gms flavor only; noGms stays FOSS-pure). - watchOS. A native SwiftUI app (
iosApp/MilewayWatch) over:sharedWatch, a headless KMP static framework exposing the same domain facade with no Compose/UI dependency — dashboard, trip list, and two-wayWatchConnectivitysync with the iPhone app. Built via XcodeGen (iosApp/project.yml); verified withxcodebuild, not the Gradle gate. - Both watch platforms share one commonMain contract:
SurfaceSnapshot(trip stats) andWatchSyncPayload/WatchSyncBridge(the serializable phone↔watch wire format) live incore:data, so Wear'sDataClientpush and watchOS'sWatchConnectivitysession drive off the identical shared model — no per-platform reimplementation of the sync contract.
Verification status by surface (what's verified here vs. pending real hardware).
Which of these CI actually gates. A ✅ below means verified — it does not always mean gated on every PR. The Quality Gate and CI workflows cover the Android/JVM columns (
assembleNoGmsDebug,testNoGmsDebugUnitTest, Roborazzi, the GMD instrumented suite) andios.ymlcompiles the shared iOS framework. Thexcodebuild -scheme MilewayWatch/MilewayWidgetsbuilds and the watchOS screenshot tests are run locally, not by any workflow — the onlyxcodebuildin CI is the release job's IPA archive, which builds theiosAppscheme.:sharedWatchand:app-web-previeware likewise not compiled by any workflow yet.
| Surface | Build/compile | Automated tests | Live/device verification |
|---|---|---|---|
| Wear OS app (dashboard, trips, tile, complication, ongoing activity) | ✅ assembleNoGmsDebug/assembleGmsDebug |
✅ testNoGmsDebugUnitTest (incl. host-rendered Roborazzi screenshots) |
⏸ on-watch GPS verification only |
| Phone→watch DataLayer sync (gms) | ✅ compiles, FOSS-purity guard passes | ✅ unit-tested | ⏸ needs a paired physical/emulated Wear device |
watchOS app (SwiftUI + :sharedWatch) |
✅ xcodebuild … -scheme MilewayWatch build |
✅ host-rendered screenshot (WatchScreenshotTests) | ✅ dashboard captured |
| WatchConnectivity sync (iOS ↔ watchOS) | ✅ compiles both schemes | — | ⏸ needs a live paired simulator/device session |
| Android Glance widget + quick start/stop | ✅ assembleNoGmsDebug |
✅ MileageSummaryWidgetTest |
— (in-process Glance render, no home-screen manual check done here) |
| Android App Shortcuts / Quick Settings tile / AppFunctions | ✅ compiles | ✅ unit-tested | ⏸ AppFunctions invocation needs adb shell on an API-36 emulator (device-gated) |
| iOS WidgetKit + Live Activity/Dynamic Island | ✅ xcodebuild -scheme MilewayWidgets build |
✅ host-rendered screenshots (WidgetScreenshotTests) | ✅ widgets + Live Activity captured |
| iOS App Intents / Siri Shortcuts | ✅ compiles, AppShortcutsProvider registered |
— | ⏸ Siri phrase invocation needs a device/simulator with Siri running |
| Compose Desktop dashboard | ✅ :desktopApp:desktopMain compiles |
✅ desktopTest (host-rendered screenshot) |
— (pure-JVM, no separate device verification needed) |
| Accessibility sweep (Android + iOS/watchOS surfaces) | ✅ compiles | — | ⏸ manual VoiceOver/TalkBack walkthrough documented inline; no automated a11y audit target yet |
The tracking pipeline is built to suppress jitter and recover from GPS spikes:
- Jitter suppression. Stationary drift gets filtered out while the anchor point is preserved.
- Spike detection. An implied-speed check flags teleporting fixes instead of silently dropping them.
- Four-bucket accounting.
original,cleaned,abnormalandmockare each persisted per track. - Mock-location flagging. Spoofing is detectable, not just blocked.
- IMU fusion. Accelerometer and gyroscope snapshots feed the post-hoc insight analyzers.
- Device-tier-adaptive sampling. Location cadence and analysis depth scale to the device's tier, so low-end hardware isn't overwhelmed while high-end devices get full fidelity.
- Config-driven abnormal detection. Spike / teleport thresholds come from one tunable config object (locally overridable), not magic numbers scattered through the pipeline.
- Deterministic DB-recompute. Distance buckets can be recomputed from the persisted points, so a math fix re-derives history instead of stranding already-tracked trips on the old numbers.
Set SIMULATE_LOCATION = true and a simulated drive source feeds believable fixes through the exact
same pipeline, so the whole tracking flow works on an emulator with no GPS hardware.
Portfolio · PaymentsLab (sibling KMP project) · kmp-build-logic (shared convention plugins)
Mileway is a portfolio / demo project. All companies, bookings, cards and amounts are fictional mock data.
























