Credential migration under R8 obfuscation, Keychain state that outlives uninstalls, and deep links from three years ago — the swap nobody blogs about.
Production Compose Multiplatform — Part 3 of 5. Part 2 — the case study.
There’s a moment in every rewrite where the engineering stops being about the new codebase. Ours arrived near the end, with a simple question: what exactly happens to the people who already have the app installed?
Part 2 told the story of rebuilding GeoKrishi Farm from 848 shared lines to a 93%-shared Compose Multiplatform codebase. This part is about the step that made the rebuild real: shipping it. Users had the old app on their phones. They had sessions, saved credentials, notification tokens, share links in old chat threads. “Install our new app” was never on the table — the new codebase had to arrive as an ordinary update on the same store listing, and a farmer opening the app the next morning had to notice nothing at all.
A rewrite the user notices is a failed rewrite. Here’s everything that took.
The Baseline Mechanics
The boring parts first, because getting them wrong ends the story early:
- Same application ID , both platforms. The store treats the new codebase as version N+1 of the existing app, nothing else.
- The next versionCode. The old app’s final release was versionCode 716; the new codebase shipped 717 and 718. One numbering line, two codebases — the store never knew a rewrite happened.
- Signing continuity on Android and the same bundle identity on iOS. Obvious, and worth stating because everything below assumes it: an update only reads its predecessor’s data if the platform believes it is its predecessor.
With that, every existing install upgrades in place — and inherits the old app’s on-device state. Which is where it gets interesting.
The Storage Layer Changed Underneath
The migrations in this section are only necessary because the rewrite didn’t reuse the old app’s storage — it replaced it.
The previous build persisted state ad hoc — scattered across preference files, each concern keying its own entries however it happened to. The new codebase routes everything through one abstraction — a CacheStore interface over encrypted vault — with typed state stores layered on top. An AuthStateStore exposes the access token and login state as observable flows; a SessionStateStore carries expiry and login-required signals; an app-language store handles locale. The session token, the user's subscription details, a half-finished signup draft, saved market and weather filters, theme, onboarding state, the biometric opt-in — all of it now lives behind that one door, under explicit, stable key names declared in a single registry.
That registry is the quiet foundation of the whole layer, and — as the next migration shows — the reason a piece of this was hard at all. Keys are plain string constants, "userToken", "loginCredentials", "userSubscriptionDetails", chosen once and never derived from a class name, a type, or anything a build tool is free to rewrite. A persisted key is a contract with every future release; the new layer treats it as one.
So “upgrade in place” meant bridging two entirely different storage schemes. For each piece of the old app’s state, one decision: carry it forward into the new keys, or drop it and let the new system re-establish it cleanly. Most of it is the second — a session is better re-authenticated than resurrected, stale preferences better re-derived than restored. Exactly one thing is worth carrying so the user never feels the seam: their saved sign-in.
Migration 1: Carrying the Saved Sign-In Across an Obfuscated Key
Carrying those credentials forward should have been a one-line read. It wasn’t, and the reason earns the section.
The last released build stored the saved username and password under a key derived from a class’s simple name. That is harmless in a debug build. In a minified release build it isn’t: R8 rewrites that class name — no keep rule pinned it — so on the shipped Android binary the on-disk key is an obfuscated, build-specific string. Read back the original name and you get nothing; the data is sitting under a single minified letter. Nothing is minified on iOS or in debug builds, so the read succeeds everywhere except the one place that ships to users.
So the migration reads defensively: try the known key first, and when that misses, identify the entry by the shape of its value — the one blob in the legacy credential store that decodes into a username and password — then write it into the new store under the new, explicit key:
// Known key first (correct on iOS and unminified builds); otherwise
// recover the entry by value shape, then write it under the NEW key name.
private suspend fun migrateLoginCredentials(): Boolean {
val legacy = legacyStorage.readString(LEGACY_FILE, LEGACY_KEY)?.let(::decode)
?: legacyStorage.readAllValues(LEGACY_FILE).firstNotNullOfOrNull(::decode)
?: return true // nothing to carry - a clean outcome
return runCatching {
cacheStore.saveModel(PreferenceKey.LOGIN_CREDENTIALS.key, legacy, serializer)
}.getOrDefault(false)
}
Three rules kept it safe, and I would carry all three into any migration:
Recover by value, not by key, when a key can move. The key was a casualty of minification; the JSON shape of the value was not.
Never overwrite fresher data. If the new store already holds credentials, the user has signed in since — the legacy entry is stale and is wiped unread, not restored over the top.
Destructive steps last, and only on a definite outcome. The source wipe and the one-shot “migration done” flag commit together, and only when the step either carried something forward or confirmed there was nothing to carry. If a blob is found but the write fails, neither commits — the next launch retries rather than destroying the only copy of someone’s saved password. The whole path is wrapped so it can never throw into launch; the worst case is a single manual sign-in, which is exactly what a fresh install asks for anyway.
The forward-looking half is the real point. This is precisely why the new layer keys every value off an explicit string constant rather than a class identifier: the class a value is modeled by can be renamed, minified, or moved between modules, but the string it was persisted under cannot follow it without a migration like this one. Decouple the two, and every release after this one reads its own data without ceremony.
Migration 2: The Keychain That Outlives the App
iOS has the inverse problem. Android’s app data dies with uninstall; the iOS Keychain does not. Delete the app, reinstall a year later — your Keychain items are still there.
For a rewrite arriving at the same bundle identity, that means a fresh install of the new app can wake up holding a stale session token from an app that no longer exists. First launch looks logged-in, with a token the backend may have expired — or worse, half-valid state that the new auth layer was never designed to interpret.
The fix is a fresh-install detector: a marker that lives where data does die with the app. NSUserDefaults is wiped on uninstall; the Keychain isn't. If the marker is missing, this launch is a fresh install — wipe the leftover session before the auth layer reads anything.
internal fun runFreshInstallKeychainWipe() {
val defaults = NSUserDefaults.standardUserDefaults
if (defaults.boolForKey(FRESH_INSTALL_MARKER)) return // no-op after the first run
runCatching {
val cacheStore = KoinPlatform.getKoin().get<CacheStore>()
SESSION_KEYS.forEach { cacheStore.deleteNoSuspend(it.key) }
defaults.setBool(true, forKey = FRESH_INSTALL_MARKER)
}.onFailure {
Log.w("Fresh-install session wipe failed; a stale session may persist", TAG, throwable = it)
}
}
Ordering is the whole point. It runs immediately after Koin starts and before a single screen composes — so the wipe always wins the race against the auth store’s first, lazy token read. The marker is persisted only if the wipe succeeded, so an interrupted first launch retries rather than silently marking a dirty install clean. And because an in-place update preserves NSUserDefaults, the marker survives, the wipe is skipped, and a logged-in user is never disturbed.
The subtle part is what it doesn’t clear. It wipes the session-bound keys — the access token, subscription state, a half-finished signup draft, saved filters — and deliberately keeps the device-level ones: theme, locale, onboarding-seen, the saved username, the biometric opt-in flag, and the biometric-sealed credential. Reinstall the app and you’re logged out, as you should be. Then one Face ID tap puts you back, rather than a full re-onboarding. “Wipe the session” and “wipe the user’s relationship with the app” are different operations, and the key list is where you say which one you meant.
(One more thing that list bought us: bumping the marker’s name forces the wipe once across the entire install base on the next update. It’s a global session-reset lever, so its value is load-bearing — change it only on purpose.)
Migration 3: Deep Links and Push Payloads From the Past
The old app had three years of artifacts in the wild: share links in chat threads, notification payloads the backend still emits, and multiple historical spellings of “where should this tap go.”
The new app’s router treats that history as part of the contract. One shared resolver — pure Kotlin, used by both platforms — accepts every payload key the backend has ever used for a link (deeplink_url, deeplinkUrl, click_action, link, uri, url), and when a legacy payload carries only a type field instead of a URL, the resolver synthesizes the correct route from it. Android registers both the custom scheme and verified HTTPS links, including the legacy path variants; iOS handles the scheme and universal links and funnels them into the same resolver.
The result: a notification sent by the backend on migration day routed correctly no matter which app version received it, and a share link from two years ago still opens the right screen today. Nothing about the rewrite was allowed to break a URL that already existed.
The Swap Checklist
Everything above, compressed into the list I wish I’d had at the start — designed at the start, because half of these constrain decisions you make on day one:
- Same application/bundle ID, same signing, next versionCode. The rewrite is an update, full stop.
- Inventory every piece of on-device state the old app created: credentials, preferences, tokens, files, Keychain items. Each one gets a decision — migrate, wipe, or deliberately ignore.
- Identify migrated data by value shape, not by key, if there is any chance obfuscation touched the keys.
- Destructive steps last, and only after verified writes. Design every migration to be retryable on next launch.
- On iOS, assume the Keychain contains ghosts. Detect fresh installs and clean before first read.
- Treat historical deep links and push payloads as API contracts. Enumerate every spelling that ever shipped; support all of them.
- Rehearse on a device that genuinely upgraded from the old app — not a clean install. The clean install always works. The upgrade is the product.
What We’ve Built
A rewrite that arrived as versionCode 717 on a listing whose users met it as an ordinary update — back in with a tap on a saved sign-in (a fresh session, not a resurrected token), notifications still routing, old links still working, on a codebase sharing 93% of its lines instead of 848.
What’s Next
Part 4 is the architecture tour: the 46+ module structure, the DI seams, and the dependency graph rendered straight from the build. If you’ve shipped a rewrite-in-place and hit a migration I didn’t — I’d genuinely like to hear it in the comments.
Drop a ❤️ if this was useful — it helps more developers find the series.

Top comments (0)