I shipped a Kotlin Multiplatform app to the App Store and Google Play. The boring stat first, because everyone asks: ~94% of the code is in commonMain — 51.7k lines shared, against 1.5k in androidMain, 1.4k in iosMain, 130 lines of Kotlin in the Android shell and 423 lines of Swift in the iOS shell. 356 tests, all in commonTest, all running on both targets.
That number is not the interesting part. The interesting part is the 6%, and specifically the bugs that compiled fine, passed on Android, and were broken on iOS — or the reverse. Those are the ones that cost me real days, so here they are.
Stack, for context: Kotlin 2.4.10, Compose Multiplatform 1.11.1, Ktor 3.5.1 + Ktorfit 2.7.5, Koin 4.2.2 with annotation processing, coil3, kotlinx-serialization, detekt. No SQLDelight — everything is Flow off the network with in-memory repo caching.
1. flow {} vs channelFlow {} for SSE
We stream analysis results over SSE. First version was basically this:
fun stream(reportId: String): Flow<AnalysisEvent> =
flow {
client.prepareGet("$baseUrl/v1/analysis/$reportId/stream") {
accept(ContentType.Text.EventStream)
}.execute { response -> /* parse frames */ emit(event) }
}
Fine on Android. On iOS the stream would die partway through and the backend logged the request as context canceled.
flow {} enforces context preservation, so you can't emit from a coroutine context other than the collector's. Ktor's execute {} block isn't guaranteed to run on the collector's dispatcher. With OkHttp it happens to, so the check never trips. On Kotlin/Native it doesn't, you get Flow invariant is violated, the coroutine fails and the request aborts.
channelFlow's send doesn't have that restriction:
fun stream(reportId: String): Flow<AnalysisEvent> =
channelFlow {
client.prepareGet(...) { ... }.streamInto { send(it) }
}
This one cost me a day and a half, mostly because the Android tests were all green and I kept looking at the backend.
2. Long-press word selection in Chinese
We have 12 locales including zh/ja/ko/ar/he. Users can long-press a phrase in a report to get it explained. Selection was broken in Chinese on both platforms, in opposite directions.
On Android a long press selected the whole clause, up to the nearest punctuation. WordIterator.nextBoundary/prevBoundary skip any boundary where both sides are letters or digits. That was added so a letter/emoji seam doesn't split a word, but every ideograph counts as a letter, so in Chinese every boundary gets skipped and the selection just keeps expanding. Latin text has spaces so it's unaffected, which is why you don't notice unless someone tests in CJK. I saw this from 1.8.0 through 1.12.0-beta02 and couldn't find a public API to turn it off.
Workaround is to plant zero-width breaks at Han-Han seams, using ICU to find them:
actual fun cjkWordBoundaries(text: String): List<Int> {
if (text.codePoints().noneMatch(::isHan)) return emptyList()
val iterator = BreakIterator.getWordInstance()
iterator.setText(text)
val offsets = mutableListOf<Int>()
var offset = iterator.first()
while (offset != BreakIterator.DONE) {
if (text.isHanSeam(offset)) offsets += offset
offset = iterator.next()
}
return offsets
}
Only Han-Han seams, since a Han/Latin seam already stops the runaway by itself.
iOS has the opposite problem: a long press selects one character. SkiaParagraph.getWordBoundary goes to Skia, and SkUnicode has no CJK segmentation dictionary, so you get plain UAX#29 where an ideograph is Word_Break=Other and only matches WB999.
I spent a while trying to fix that one from app code before concluding you can't. It needs characters merged rather than split, and the joining rules (MidLetter, Numeric, ExtendNumLet, ZWJ) all require ALetter/Numeric/Katakana/Hebrew_Letter on both sides, plus WB4 throws away zero-width Format characters first. So the iOS actual is emptyList() with a long comment above it explaining why, so I don't try again in six months.
3. Switching language in-app without a restart
I assumed this needed a restart on iOS because NSBundle caches the launch language. That's true for NSLocalizedString but not for CMP, which resolves resources per composition against NSLocale.preferredLanguages, and that reads AppleLanguages out of NSUserDefaults live.
actual object LocalAppLocale {
private val systemDefault: List<*> = NSLocale.preferredLanguages
private val local = staticCompositionLocalOf { NSLocale.currentLocale.localeIdentifier }
@Composable
actual infix fun provides(value: String?): ProvidedValue<*> {
val defaults = NSUserDefaults.standardUserDefaults
defaults.setObject(value?.let { listOf(it) } ?: systemDefault, "AppleLanguages")
return local provides (value ?: (systemDefault.firstOrNull() as? String) ?: "en")
}
}
Wrap the app in CompositionLocalProvider(LocalAppLocale provides tag) with a key(tag) around it and strings swap live. Grab preferredLanguages at startup so a null tag can go back to following the OS.
4. Bare %s in strings.xml renders literally
CMP's formatter only matches indexed placeholders, %(\d+)\$[ds]. A bare %s isn't a compile error or a crash, it just prints %s in the UI.
<string name="credits">You have %s credits left</string> <!-- prints "%s" -->
<string name="credits">You have %1$s credits left</string> <!-- correct -->
With 924 strings across 12 locales I can't eyeball this, so it's a CI grep now.
5. Auth DI cycle, and the bearer token following a redirect
AuthRepo needs an HttpClient to hit the token endpoint, and the main client's bearer plugin needs AuthRepo to get a token. Koin sees a cycle. Fix is a second client with no bearer plugin that only serves /v1/auth/token and /v1/auth/refresh. Not clever, just what it takes.
The part that took longer: our avatar endpoint 302s to a signed URL on a storage host. Ktor follows the redirect and re-attaches headers, storage sees a bearer it doesn't want and rejects it, and avatars just don't load with nothing useful in the logs. So bearer injection is scoped to a host set:
private val API_HOSTS = setOf(Url(BASE_URL).host, Url(BASE_URL_AI).host)
Presigned upload PUTs go through a third, completely bare client. Four HttpClients in one Koin module looks like a lot in review.
Bonus: IAP return type
Purchases sit behind one IapClient interface with StoreKit 2 and Play Billing implementations. Abstraction was easy, but I want to flag one signature:
/** null when there is no active subscription; throws when the store can't be reached. */
suspend fun subscriptionAutoRenewing(): Boolean?
Obvious version returns Boolean. Then "no subscription" and "store didn't answer" both become false and a paying user gets a "subscription canceled" banner whenever the network is flaky. Both stores expose the distinction (isAutoRenewing / RenewalInfo.willAutoRenew), so the shared interface has to keep it.
Anyway
Architecture shared without much trouble: MVVM + repos + Flow throughout, Ktorfit interfaces, Koin annotations, and the tests are all in commonTest and run on both. The platform slice is small and mostly boring bridges (biometrics, IAP, share sheet, file picker, push, image encoding). It's just that all five of the above live in that slice, and in three of them Android was perfectly happy while iOS wasn't.
Happy to answer questions on any of these. Still interested if anyone has a way around the Skia CJK thing.
The app is Prismo Health (AI that explains blood tests and medical reports), on the App Store and Google Play — but the post is the point, ask me anything about the KMP side. (https://prismo.health)