Eight pull requests. Fifty-seven commits. Not one day where Burrow’s Android build stopped working. That was the actual constraint on this migration — not “get to iOS,” but “never let Android regress while you do it.”
Burrow is my personal expense and password manager: offline-first, SQLDelight for storage, biometric-gated vault, a Gemini-powered receipt scanner behind a Supabase edge function. It’s a real shipped app on the Play Store, used daily by exactly one demanding user — me. I wanted an iOS version. I did not want to maintain two codebases, and I did not want a six-month rewrite branch that quietly rots while real features stop shipping.
The rule that made this survivable
The design doc had one non-negotiable line: every phase is a mergeable PR, gated on assembleDebug + testDebugUnitTest passing, and from Phase 1 onward, compileKotlinIosSimulatorArm64 passing too. No phase merges unless Android still builds and tests still pass.
That sounds obvious. It isn’t. The tempting version of this migration is a long-lived kmp-migration branch where everything is broken until the big reveal. I’ve watched those branches die — not from bad architecture, but from the team losing the thread of what “done” even means after month four. Small, gated, sequential PRs meant I could stop after any phase and still ship an Android update.
Phase 0 Dependency runway (Android only)
| Coil3, Koin4, Ktor, kotlinx-datetime
v
Phase 1 shared/ module skeleton (empty, both targets build)
|
v
Phase 2 domain + core ~90 files, pure Kotlin
|
v
Phase 3 data + SQLDelight ~30 files, expect/actual DB
|
v
Phase 4 resources sweep strings.xml to Res.string
|
v
Phase 5 presentation + ui ~85 files, screens + VMs
|
v
Phase 6 iosApp shell + actuals first real iOS build
|
v
Phase 7 polish + release plumbing
gate at every arrow: assembleDebug + testDebugUnitTest green
Phase 0 exists to separate two kinds of risk
Before shared/ existed at all, Phase 0 swapped out libraries inside the plain Android app: Coil 2 to Coil 3, Koin Android to Koin core, AndroidX navigation/lifecycle to their JetBrains KMP equivalents, direct OkHttp to Ktor, java.time to kotlinx-datetime.
None of that needed a multiplatform module to exist. The point was isolating “this library upgrade changed behavior” from “this module boundary broke something.” If a screen misbehaved after Phase 0, I knew immediately it was Coil 3’s fault, not some new expect/actual wiring. Doing both at once would have meant debugging two unknowns with one symptom.
The compiler finds every JVM assumption you didn’t know you had
Phases 2, 3, and 5 moved code into commonMain, and each one turned up Android/JVM assumptions that had been invisible for years because nothing ever asked the code to run anywhere else:
System.currentTimeMillis()everywhere →Clock.System.now().toEpochMilliseconds()SimpleDateFormat/java.util.Date/java.util.Locale→ kotlinx-datetime formattingandroidx.core.graphics.toColorInt()→ a pure-Kotlin hex parserkotlin.jvm.Volatile→kotlin.concurrent.VolatileDispatchers.IO(JVM-only) →Dispatchers.Defaultmaterial-icons-extendedicons likeDocumentScannerandVpnKey→ core icon set equivalents"%,.0f".format()→ a hand-writtenDouble.formatAsAmount()
None of these are hard fixes individually. What’s notable is the volume: dozens of small, previously-invisible couplings, each one a place where “Android app” and “Kotlin code” had quietly become synonyms. The iOS compile target is a forcing function you can’t argue with — it either compiles for iosSimulatorArm64 or it doesn’t.
One case in Phase 2 was subtler than a missing import. RupiahFormatter was named in the migration task as something to move, but it delegated to CurrencyFormatter (which wraps java.text.DecimalFormat) and referenced a Currency model that hadn’t moved yet. Grepping for android/java imports wouldn’t catch it, because the dependency was same-package with no import statement at all. Moving it would have made shared depend on app, which Gradle’s module graph forbids. The fix was to leave it behind and move it in a later phase — a reminder that automated migration heuristics catch syntax, not module topology.
The bug that compiled fine and crashed at runtime
Phase 5 produced the one failure that didn’t show up until the app actually launched. app/di/viewModelModule.kt and shared/commonMain/di/viewModelModule.kt both compiled to a JVM facade class named ViewModelModuleKt. Same name, two modules, both on the runtime classpath. The build succeeded. The app threw NoSuchMethodError on launch.
Nothing in the type system flags a JVM facade name collision across Gradle modules — it’s a runtime linking problem masquerading as clean code. The fix was renaming the Android-side file to androidViewModelModule.kt. Cheap fix, but it’s the kind of bug that only exists because Kotlin file-level functions compile to a class named after the file, and two files with the same name in two modules on the same classpath is legal right up until the JVM tries to resolve a call.
The one seam where a silent bug would be genuinely dangerous
Everything else in this migration, a bug means a crash or a wrong pixel — annoying, visible, fixable. The CryptoService expect/actual is different. Android uses Keystore-backed AES/CBC; iOS uses a Keychain-held key with CommonCrypto. Both have to produce and consume the exact same "<iv>:<ciphertext>" string format, because that string is what’s sitting in the SQLDelight database.
A subtle mismatch here — a byte order difference, a padding scheme difference — wouldn’t fail loudly. It would fail as “some vault entries silently don’t decrypt,” which is close to the worst possible failure mode for a password manager. That’s why this is the one seam with a dedicated shared test vector in commonTest: both platform actuals encrypt and decrypt the same known plaintext and must agree on the wire format. Compile-time platform gates get you API parity. They don’t get you byte-for-byte parity. Only a test that runs both actuals against the same input does that.
What it added up to
Fifty-seven commits across eight merged phases, roughly 90 domain files, 30 data files, and 85 presentation files moved into shared, and one dependency runway that touched the entire app before a single line moved. Every one of those commits landed on main with Android green. By Phase 6, iosApp was booting the same Compose screens Android had been running since Phase 5 — not a port, the actual same code.
The discipline that made this possible wasn’t a clever architecture decision. It was refusing to accept “temporarily broken” as a valid state at any point in an eight-phase, multi-week migration. Migrations don’t usually fail because the target design was wrong. They fail because somewhere around phase three, someone lets the build go red “just for now,” and now never quite arrives.