Skip to content

Latest commit

 

History

834 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Mileway — offline-first mileage, travel and expense tracking

Offline-first mileage, travel and expense tracking, built in Kotlin and Compose Multiplatform.

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.

CI Quality Kotlin Compose Multiplatform Platforms Backend

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 glance46-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 from settings.gradle.kts by scripts/gen-readme.sh.

Why Mileway

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.

Highlights

  • 🛰️ 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 :server module (Ktor + Exposed) speaks the same :contract DTOs as the client, with idempotent location/event ingestion (opId dedup against a unique DB index) and the identical PolicyRateEngine computing reimbursement amounts on both sides. Off by default behind NetworkBackendFlags.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 :app composition 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-krossmap compile for iosArm64/iosSimulatorArm64 — that's real compile parity, not an Android app with a port pending. What differs today is the navigation surface: iOS renders MilewayApp(), 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 :app and 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 behind expect/actual.
  • 🔀 One codebase, two distributions. A gms Play build and a FOSS noGms / 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 commonMain architecture.
  • 📄 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.

Screenshots

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)
Super-profile & personas
One account hub that reshapes itself — the Plugin manager toggles every feature per persona, so the same app becomes a different app.

Account hub, profile details, plugin manager, preferences and settings in sequence
Track a trip
The core happy path: ready-to-start → setup guide → recording → success summary → submission.

Track Miles idle through recording to the success and submission screens
Delegation — acting as a manager
Assign approver authority, then switch into the manager view to track the whole team's journeys.

Delegation assignment into the manager reportees tracking view
Log & expense
Manual entry: log miles across two steps, then capture an expense with category, policy and receipt.

Log-miles two-step, expense entry, details input and expense detail
Approvals & payables
The approver side: pending queue with policy badges, a flagged detail, payables and purchase requests.

Approvals queue, a violation detail, payables hub and purchase-request create/detail
Verification & growth
Identity verification centre, referral hub, coupons, scratch-card rewards and the campaign marketing hub.

Verification centre, referral hub, coupons, rewards and marketing hub
Membership & subscription
Mileway Club benefits, subscription plans, the active subscription and incentive programs.

Club benefits, subscription plans, my subscription and incentive programs
AI assistant
On-device expense chat, saved history and question analytics behind a shared LlmGateway.

Assistant chat, conversation history and chat analytics
Onboarding & auth
First run: splash → login → signup onboarding → set a PIN → unlock.

Splash, login, signup onboarding, set-PIN and check-PIN
Wallet & payout
Connected accounts (per-integration connect/disconnect) and the QR scan-to-pay identity screen.

Connected accounts and QR pay/identity
Account & sessions
Active-device sessions with per-device revoke, the account-deletion lifecycle, saved places and emergency contacts.

Active sessions, account deletion, saved places and emergency contacts

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.

Wear OS

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
Wear OS dashboard with today/week distance cards and week-goal ring Wear OS recent-trips list

watchOS app

Native SwiftUI over the :sharedWatch KMP framework — today/week distance, a red live-tracking pill and a trips drill-down.

 
watchOS dashboard with amber distance, red Tracking pill and a Trips button

Live Activity & Dynamic Island

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)
Live Activity banner: Tracking, 12.4 km, elapsed 12:34 Dynamic Island expanded: distance, elapsed time and tracking status

Widgets

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
Android Glance home-screen widget with today/week distance and a red live-tracking indicator iOS home-screen widget with today/week distance and a Stop button iOS Lock Screen accessory widget with today's distance

Compose Desktop (dashboard preview)

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
Compose Desktop dashboard window with today/week stats and a recent-trips list Compose Desktop live-tracking screen with a compass gauge, journey stats and start/pause/stop controls Compose Desktop trip history list with tabs and per-trip distance/duration
Trip detail Log expense Approvals
Compose Desktop trip detail screen with a status timeline (submitted / under review / approved) Compose Desktop log-expense form linked to a trip, with amount and category Compose Desktop approvals queue with pending/approved/rejected tabs
Profile
Compose Desktop profile screen with a completion banner and an account tile grid

Features

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.

Architecture

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
Loading

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 behind expect/actual interfaces in :core:platform.
  • Koin DI. One module per feature, and the InitKoin() bootstrap is re-entrancy-safe for both the Android Application and the iOS entry point.
  • SearchProvider registry. Each feature binds a SearchProvider into Koin. The master-search aggregator resolves getAll<SearchProvider>() and fans out, so search and the features stay decoupled.
  • Shared scaffolds. FormSubmissionScaffold and HistoryListScaffold standardise 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. :server and the client both depend on :contract for request/response DTOs and the PolicyRateEngine, so the wire format and the reimbursement math can't drift between them. NetworkBackendFlags.useRealBackend (default false) is the single switch between :stub's mock data and the real Ktor calls; writes queue through a durable offline outbox either way.

Engineering decisions

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 map

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)

Project structure

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

Tech stack

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

Getting started

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.apk

No 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)

Build flavors

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  # 19

Getting 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.

Ralph-loop development

This repo is built and evolved almost entirely through autonomous Ralph-loop iteration (.ralph/PLAN.md plus versioned plans PLAN_V3PLAN_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):
    ./gradlew assembleNoGmsDebug && ./gradlew testNoGmsDebugUnitTest
    (the gms flavor crashes Robolectric, so unit tests only run on noGms.)
  • 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 / testDebugUnitTest from 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.

Testing and quality

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.ymlBuild & 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.

Roadmap

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; commonMain core compiles for Android + iOS
  • gms / noGms flavor 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 commonMain and 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: ContentView hosts MilewayApp(), 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 :app and 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). LlmGateway swaps 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/WatchSyncPayload domain contract in core:data; a full Wear OS app (dashboard, trip list/detail, tile, complication, ongoing activity, phone→watch DataClient sync); a native SwiftUI watchOS app over the new headless :sharedWatch KMP framework with two-way WatchConnectivity sync; 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) and core: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 :server module (Ktor + Exposed, H2 by default) alongside a shared :contract module for request/response DTOs and the PolicyRateEngine, so server and client can't drift on wire format or reimbursement math; idempotent location/event ingestion (opId dedup against a unique DB index); a JourneyValidator/DistanceValidator validation layer; writes queued through a durable offline outbox and flushed once online. Off by default behind NetworkBackendFlags.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's jwt("jwt") provider; /api/auth/login and /api/auth/refresh are the only open routes and every other /api/* route sits inside one authenticate("jwt") block. Client side: an AuthTokenStore (access token in memory, ~30d refresh token in the toolkit's encrypted settings — Keychain / EncryptedSharedPreferences), an AuthApi, and withBearerAuth() layering Ktor's bearer{} provider on for attach-and-refresh-once-on-401. Covered by :server's AuthRoutesTest and core:network's AuthTokenStoreTest/BearerAuthFlowTest — but not yet exercised by a running client: NetworkBackendFlags.useRealBackend is still false and nothing flips it at runtime, so no app build has actually called an authenticated route.
  • iOS launch-crash fix (V33). CADisableMinimumFrameDurationOnPhone added to Info.plist (a Compose Multiplatform PlistSanityCheck requirement) — 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.useRealBackend is a compile-time false with 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, Wear OS and watchOS

  • iOS. Every :core:* module compiles to an iOS framework, with expect/actual services backed by CoreLocation, Vision (OCR), UserNotifications, LocalAuthentication and BackgroundTasks. A few proprietary integrations (in-app update, install-referrer) are stubbed with TODO(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 offline SurfaceSnapshot/WatchFacade seam as the phone app.
  • Wear OS. :wear is a full Compose-for-Wear-OS app: a ScalingLazyColumn dashboard (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→watch DataClient sync (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-way WatchConnectivity sync with the iPhone app. Built via XcodeGen (iosApp/project.yml); verified with xcodebuild, not the Gradle gate.
  • Both watch platforms share one commonMain contract: SurfaceSnapshot (trip stats) and WatchSyncPayload/WatchSyncBridge (the serializable phone↔watch wire format) live in core:data, so Wear's DataClient push and watchOS's WatchConnectivity session 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) and ios.yml compiles the shared iOS framework. The xcodebuild -scheme MilewayWatch / MilewayWidgets builds and the watchOS screenshot tests are run locally, not by any workflow — the only xcodebuild in CI is the release job's IPA archive, which builds the iosApp scheme. :sharedWatch and :app-web-preview are 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 location engine

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, abnormal and mock are 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.

About

Mileage & trip tracking app — Compose Multiplatform across 5 platforms, staged dead reckoning with Kalman smoothing and MotionFusion gravity/linear-acceleration filtering, ML Kit odometer OCR, offline-first with a Ktor backend

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages