DATA CONSULTING SERVICES

Porting Stay Steady to Android — Our Experience with Kotlin, Jetpack Compose, and On-Device AI

by Data Consulting Services
AndroidKotlinJetpack ComposeOn-Device AILiteRT-LMGemmaIndie DevStay Steady

When we launched Stay Steady on iOS, the first message we got — within hours — was: “Looks great. When’s the Android version?”

It was a fair question. Roughly two-thirds of European smartphone users are on Android. A “privacy-first budgeting app that works in your language” is wasted potential if it only ships to one platform. So we set out to bring the same experience to Android — and the version you can now download from Google Play is the result.

This is the story of how we did it: the technology choices, the trade-offs, and the things we’d do differently next time.

The big decision: native Android, not cross-platform

The first decision was the loudest: don’t re-skin the iOS app. We considered every cross-platform option. React Native (familiar to web devs, mature ecosystem). Flutter (good performance, beautiful animations). Kotlin Multiplatform Mobile (share business logic, native UI). Each has merit. We picked native Android with Kotlin and Jetpack Compose — meaning a fresh codebase, no shared layer with iOS, no shim layer translating SwiftUI views to Android widgets.

Three reasons:

  1. On-device AI is platform-specific. Apple’s Core ML and Foundation Models APIs have no Android equivalent. Android’s equivalent stack — TFLite, MediaPipe, LiteRT-LM — has no iOS equivalent. The moment we tried to put a cross-platform layer between us and the model runtime, we’d be debugging through two abstractions instead of one.

  2. The user experience differs. Android users expect Material 3 motion, system-coloured surfaces, predictive back gestures, edge-to-edge layouts, Credential Manager for sign-in (not OAuth in a browser). iOS users expect SwiftUI rhythm, Liquid Glass effects on iOS 26+, Sign in with Apple, Shortcuts integration. A shared layer flattens both into a generic experience that pleases neither.

  3. Native is no longer slow to build. Jetpack Compose has caught up to SwiftUI in expressiveness. Kotlin coroutines map cleanly to Swift’s async/await. Room is a strong analogue to SwiftData. The cost of “two codebases” is much lower than it used to be, especially when an LLM-assisted developer can keep both in mental sync.

The result is two apps that feel deeply right on their respective platforms, but stay aligned where it matters — same data model, same backend, same product values.

The stack

LayerChoiceWhy
LanguageKotlinFirst-class Android, null-safe, coroutines
UIJetpack ComposeModern declarative UI, Material 3 motion
ArchitectureModular (app, core, data, feature/*)Mirrors iOS package structure
PersistenceRoom (schema 3)Strong SwiftData analogue, good migration tooling
DIHiltStandard, well-integrated with Compose
Background workWorkManagerReliable scheduling on modern Android
On-device AILiteRT-LM + Gemma 4The Android answer to Apple’s Foundation Models
CategorizerTFLite (encoder + classifier head)Same architecture as iOS Core ML model
AuthGoogle Sign-In via Credential ManagerModern Android auth, replaces the old GoogleSignIn API
BackendSupabase (shared with iOS)Same auth, same contributions table, same ML model distribution
Min SDK / Target30 / 36Android 11+ baseline, target Android 16.1

The full module tree, schema migrations, and architectural decisions are tracked in PORT_PLAN.md and PROGRESS.md inside the Android repo — append-only logs we kept religiously so the next developer (or our future selves) could pick up the trail.

Where iOS and Android diverge

The hardest discipline of a parallel-platform indie team is deciding when not to make the two versions identical.

Auth. iOS has Sign in with Apple — fast, private, no friction. Android doesn’t, and Apple ID sign-in on Android is a customer-support nightmare we explicitly didn’t want. So Android uses Google Sign-In via Credential Manager. Apple Sign-In was removed entirely in milestone S1 (2026-05-08). The Account section on Android shows one option: “Sign in with Google.”

Sync. iOS users get optional iCloud sync between their iPhone and iPad — piggybacking on CloudKit and the user’s Apple ID. Android v1 doesn’t have an equivalent cross-device sync; it’s local-only. Adding Google Drive sync is on the roadmap but isn’t a v1 blocker — the people asking for Android wanted the app, not the sync.

On-device conversational AI. iOS 26+ uses Apple’s Foundation Models for “ask your budget” chat. On Android we ship Gemma 4 via LiteRT-LM — and crucially, RAM-tiered: devices with 4–8 GB get the E2B variant (1.9 GB on disk), devices with ≥8 GB get the E4B variant (2.8 GB). Loading a 2.8 GB model on a mid-range phone with 4 GB of RAM is a great way to get killed by the OOM killer and a terrible user experience. We learned this the hard way during testing.

Localisation. Stay Steady on iOS is trilingual (EN/NL/FR). On Android we added German in the same pass — a low-cost win for the DACH market that we couldn’t justify on iOS until 1.2.

The gotchas

A few things bit us hard enough to be worth flagging.

Room schema migration crash. Our first NetWorthItem migration declared id BLOB NOT NULL, but our Converters.fromUuid returns a String (TEXT). Room’s MasterTable validation rejected the mismatch with IllegalStateException on cold launch with a v2 database. The fix was one line — BLOBTEXT in Migrations.kt:49 — but recovering required adb shell pm clear net.dataconsultingservices.staysteady on every dev device that had touched the broken schema. Lesson: always smoke-test migrations against a real v(n-1) database, not just a fresh install.

OutlinedTextField + clickable dropdown. We tried using Modifier.clickable { categoryMenuExpanded = true } on a Compose OutlinedTextField to open a category picker. The text field’s internal pointer handling swallowed the click before our handler ran. Fix: use ExposedDropdownMenuBox + menuAnchor() instead. This is the Compose equivalent of a SwiftUI gesture conflict — the abstraction leaks differently on each platform.

GenAI session lifecycle. Our first GenAI integration kept the same conversation alive across multiple “ask your budget” generations and got degenerate multi-turn output — the model started parroting earlier prompts. The fix was starting a fresh Conversation per generation. Stateless turns are cheap; stateful chains are not.

The Compose preview-vs-runtime gap. Compose previews are fast and beautiful — until you put a @HiltViewModel behind them. Then you discover the preview-only test doubles your design needed three weeks ago. We rebuilt several screens around stateless @Composables that take their data as parameters specifically so previews would keep working without DI shenanigans.

What the LLM-assisted workflow looked like

We’re not a 200-person Android team. We’re a tiny indie shop with an LLM-assisted workflow. Two patterns that genuinely worked:

  • Append-only progress logs (PROGRESS.md, newest-first). Every milestone got a short paragraph with relevant file paths and a one-line note on what wasn’t done yet. When we picked up work after a few days, the log told us where we’d left off without re-deriving context from git log.
  • A separate PORT_PLAN.md holding the long-form roadmap, with milestone IDs (F4b, S1, S2, S3…) that we referenced in commit messages and progress entries. The plan answered “what’s next”; the progress log answered “what’s done.” Keeping them separate made both easier to read.

If you’re a small team or a solo dev shipping a parallel iOS + Android port, treat these markdown files as first-class engineering deliverables. They paid off more than any task tracker we tried.

Where Stay Steady Android is today

Stay Steady on Android is live on Google Play — free forever, no in-app purchases, no bank connection, all data on-device. Same product values as iOS, same backend, same Belgian-bank import support, with the platform-specific touches Android users actually expect.

If you’re considering a similar port — from iOS to Android or the other way — and want to compare notes on Kotlin + Compose, on-device AI with LiteRT-LM, or the broader experience of running a two-platform indie codebase, we’d love to hear from you: apps@dataconsultingservices.net.

Two apps, one product, two real platforms — that’s the only way we know to do it right.