
r/KotlinMultiplatform

I couldn't find a clean way to add AdMob in Compose Multiplatform, so I built one
AdMob in Compose Multiplatform is easy until the app needs more than a banner.
Consent, ATT, full-screen ownership, native-ad reuse and iOS test linking all cross the shared/native boundary.
I built AdMob CMP to manage that machinery:
Working as Android dev want to switch to KMP dev
Hi, I have been working on Android for 5 years. And recently I have been working on a dummy KMP app with my own BE through ktor.
It's been a great learning experience. And migration from Android to KMP was not tough.
But finding jobs in the current market for KMP. It's a tough fight. As companies who are starting with KMP ask Android developers to migrate only. And it's still a bet so they do not hire KMP devs.
Where can I find KMP roles. The startup who starts with KMP does not want a developer with 5 years of experience. The big company has their own developers.
Please help.
94% shared code — and 100% of my hard bugs were in the other 6%
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)
A single KMP dependency dyld-crashed our iOS app at launch on every device below iOS 26 — and no simulator caught it. Here's the trap.
Sharing a painful one in case it saves someone a bad week.
I build a fitness app in Kotlin Multiplatform + Compose (shared Android/iOS). Everything ran perfectly in dev and on TestFlight. Then a user on iOS 18.x sent a video: instant white-screen death on launch, before anything rendered.
Root cause: a KMP library I'd added referenced HealthKit symbols (HKMedicationGeneralFormCapsule and friends) that only exist in the iOS 26 SDK. Kotlin/Native emits those as strong dyld imports in the statically-linked framework — so on any device below iOS 26, dyld can't resolve the symbol and kills the process before main(). Symbol not found. No stack trace in the app, no crash in Crashlytics-style tools, nothing — it dies before your code runs.
Why every test missed it: all my simulators and devices were on iOS 26.x. The bug is completely invisible on new OSes and fatal on old ones. You have to run a min-deployment-target OS to see it (I had to download an 18.5 runtime).
The fix (one line, iOS target's OTHER_LDFLAGS):
-Wl,-weak_framework,HealthKit
Weak-linking makes the missing symbols resolve to null instead of aborting; everything I actually use exists on 18.x, so behavior is unchanged.
Two takeaways I'm now religious about:
- Smoke-test every release on your minimum supported OS, not just the latest.
- Before shipping, check for new-SDK-only symbols:
nm -u <binary> | grep -i <framework>.
Bumping any KMP lib that's compiled against a newer SDK is where this bites. Hope it saves someone the panic.
KMP Starter Template just hit 150 stars
My open-source KMP Starter Template just reached 150 GitHub stars, so I wanted to share it here.
It's a multi-module Kotlin Multiplatform boilerplate for Android and iOS, built around Clean Architecture.
It handles a lot of the boring setup that I find myself repeating in every KMP project:
- Remote Config
- In-app purchases with RevenueCat
- Analytics with Mixpanel
- Koin
- DataStore
- Room
- Multiple languages
- InAppReview / InAppUpdate
- Native bindings
- UI utilities and components
- Logging
- Platform/version utilities
The main idea is to spend less time setting up infrastructure and more time building the actual app.
I've also been working on turning its modules into libraries, so existing KMP projects can adopt individual features without having to fork the entire template.
150 stars isn't huge by GitHub standards, but seeing people actually use and star something I originally built because I was tired of repeating my own setup feels pretty good.
Repo: https://github.com/DevAtrii/Kmp-Starter-Template
Would love to hear what KMP developers think is missing from the template.
New NES Emulator
Hello guys!
I am a software engineer and I've always wanted to develop an emulator. My favourite platform is definitely the NES.
I am developing a new NES emulator to sharpen my skills with a specific technology: Kotlin Multiplatform. It can run the same code on Web, Desktop and, potentially (not supported yet) mobile.
It is still in very early development stages, so you'll definitely find some bugs or compatibility issues but you should be able to play some of your favourite games with a gamepad or a keyboard.
It should be playable even on mobile with a decent performance.
Some useful links:
If you like more story-telling, I've also started a blog.
koin crashes
Hi! I'm starting with KMP. I want to learn Koin, buth my application crashes in the highlighted line. What else do I need to do to register the singleton and module?
No definition found for type 'com.rhuertas.kointest1.Unisono' on scope '['_root_']'.. Check or add definition for type 'com.rhuertas.kointest1.Unisono' in scope '_root_'.
org.koin.core.resolution.CoreResolverV2.resolveFromContext(CoreResolverV2.kt:223)
org.koin.core.scope.Scope.resolveFromContext(Scope.kt:330)
org.koin.core.scope.Scope.stackParametersCall(Scope.kt:293)
org.koin.core.scope.Scope.resolveInstance(Scope.kt:279)
org.koin.core.scope.Scope.resolve(Scope.kt:252)
org.koin.core.scope.Scope.get(Scope.kt:234)
com.rhuertas.kointest1.ComposableSingletons$AppKt.lambda__186604527$lambda$0(App.kt:177)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:408)
org.koin.compose.KoinApplicationKt.KoinApplication(KoinApplication.kt:188)
com.rhuertas.kointest1.AppKt.App(App.kt:39)
com.rhuertas.kointest1.ComposableSingletons$MainKt.lambda__1001413051$lambda$0(main.kt:11)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:131)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$setContent$1$1.invoke(DevelopmentEntryPoint.kt:119)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$setContent$1$1.invoke(DevelopmentEntryPoint.kt:118)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
org.jetbrains.compose.reload.jvm.ReloadEffectsKt.OverlayLayout(ReloadEffects.kt:68)
org.jetbrains.compose.reload.jvm.ReloadEffectsKt.ReloadEffects(ReloadEffects.kt:44)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$DevelopmentEntryPoint$intercepted$1.invoke(DevelopmentEntryPoint.kt:80)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$DevelopmentEntryPoint$intercepted$1.invoke(DevelopmentEntryPoint.kt:76)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$DevelopmentEntryPoint$4.invoke(DevelopmentEntryPoint.kt:104)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$DevelopmentEntryPoint$4.invoke(DevelopmentEntryPoint.kt:102)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:428)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint.DevelopmentEntryPoint(DevelopmentEntryPoint.kt:102)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$setContent$1.invoke(DevelopmentEntryPoint.kt:118)
org.jetbrains.compose.reload.jvm.JvmDevelopmentEntryPoint$setContent$1.invoke(DevelopmentEntryPoint.kt:117)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:131)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.ui.awt.ComposeWindow.setContent$lambda$0(ComposeWindow.desktop.kt:176)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.ui.awt.WindowContentLayout_desktopKt.WindowContentLayout(WindowContentLayout.desktop.kt:105)
androidx.compose.ui.awt.ComposeWindowPanel.setContent$lambda$0$0(ComposeWindowPanel.desktop.kt:156)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:428)
androidx.compose.ui.awt.ComposeWindowPanel.setContent$lambda$0(ComposeWindowPanel.desktop.kt:153)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.ui.scene.ComposeSceneMediator.setContent$lambda$0$0$0$0(ComposeSceneMediator.desktop.kt:645)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.ui.layout.OverlayLayout_skikoKt.OverlayLayout(OverlayLayout.skiko.kt:76)
androidx.compose.ui.viewinterop.InteropContainer_skikoKt.TrackInteropPlacementContainer(InteropContainer.skiko.kt:142)
androidx.compose.ui.viewinterop.SwingInteropContainer.invoke$lambda$0(SwingInteropContainer.desktop.kt:276)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:428)
androidx.compose.ui.viewinterop.SwingInteropContainer.invoke(SwingInteropContainer.desktop.kt:273)
androidx.compose.ui.scene.ComposeSceneMediator.setContent$lambda$0$0$0(ComposeSceneMediator.desktop.kt:644)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:408)
androidx.compose.ui.platform.CompositionLocals_skikoKt.ProvidePlatformCompositionLocals(CompositionLocals.skiko.kt:107)
androidx.compose.ui.scene.BaseComposeScene$setContent$1$2.invoke(BaseComposeScene.skiko.kt:146)
androidx.compose.ui.scene.BaseComposeScene$setContent$1$2.invoke(BaseComposeScene.skiko.kt:145)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:408)
androidx.compose.ui.platform.CompositionLocalsKt.ProvideCommonCompositionLocals(CompositionLocals.kt:237)
androidx.compose.ui.platform.Wrapper_skikoKt.setContent$lambda$0$0(Wrapper.skiko.kt:40)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:408)
androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:448)
androidx.compose.ui.platform.Wrapper_skikoKt.provide(Wrapper.skiko.kt:53)
androidx.compose.ui.platform.Wrapper_skikoKt.setContent$lambda$0(Wrapper.skiko.kt:39)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
androidx.compose.runtime.internal.Expect_jvmKt.invokeComposable(Expect.jvmAndAndroid.kt:26)
androidx.compose.runtime.GapComposer.doCompose-aFTiNEg(GapComposer.kt:2643)
androidx.compose.runtime.GapComposer.composeContent--ZbOJvo$runtime(GapComposer.kt:2545)
androidx.compose.runtime.CompositionImpl.composeContent(Composition.kt:895)
androidx.compose.runtime.Recomposer.composeInitial$runtime(Recomposer.kt:1178)
androidx.compose.runtime.CompositionImpl.composeInitial(Composition.kt:732)
androidx.compose.runtime.CompositionImpl.setContent(Composition.kt:699)
androidx.compose.ui.platform.Wrapper_skikoKt.setContent(Wrapper.skiko.kt:38)
androidx.compose.ui.scene.CanvasLayersComposeSceneImpl.createComposition(CanvasLayersComposeScene.skiko.kt:218)
androidx.compose.ui.scene.BaseComposeScene.setContent(BaseComposeScene.skiko.kt:145)
androidx.compose.ui.scene.ComposeSceneMediator.setContent$lambda$0(ComposeSceneMediator.desktop.kt:643)
androidx.compose.ui.scene.ComposeSceneMediator.onComponentAttached(ComposeSceneMediator.desktop.kt:606)
androidx.compose.ui.scene.ComposeContainer.addNotify(ComposeContainer.desktop.kt:305)
androidx.compose.ui.awt.ComposeWindowPanel.addNotify(ComposeWindowPanel.desktop.kt:133)
java.awt.Container.addNotify(Container.java:2804)
javax.swing.JComponent.addNotify(JComponent.java:4847)
java.awt.Container.addNotify(Container.java:2804)
javax.swing.JComponent.addNotify(JComponent.java:4847)
java.awt.Container.addNotify(Container.java:2804)
javax.swing.JComponent.addNotify(JComponent.java:4847)
javax.swing.JRootPane.addNotify(JRootPane.java:721)
java.awt.Container.addNotify(Container.java:2804)
java.awt.Window.addNotify(Window.java:812)
java.awt.Frame.addNotify(Frame.java:495)
java.awt.Window.pack(Window.java:850)
androidx.compose.ui.util.Windows_desktopKt.setSizeImpl-6HolHcs(Windows.desktop.kt:115)
androidx.compose.ui.util.Windows_desktopKt.setSizeSafely-hQcJfNw(Windows.desktop.kt:54)
androidx.compose.ui.awt.SwingWindow_desktopKt.SwingWindow$lambda$25$0(SwingWindow.desktop.kt:306)
androidx.compose.ui.awt.SwingWindow_desktopKt.SwingWindow$lambda$7$0(SwingWindow.desktop.kt:134)
androidx.compose.ui.awt.AwtWindow_desktopKt.AwtWindow$lambda$3$0(AwtWindow.desktop.kt:79)
androidx.compose.ui.util.UpdateEffect_desktopKt.UpdateEffect$lambda$3$0$performUpdate$1(UpdateEffect.desktop.kt:59)
androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:759)
androidx.compose.ui.util.UpdateEffect_desktopKt.UpdateEffect$lambda$3$0$performUpdate(UpdateEffect.desktop.kt:55)
androidx.compose.ui.util.UpdateEffect_desktopKt.UpdateEffect$lambda$3$0(UpdateEffect.desktop.kt:64)
androidx.compose.runtime.DisposableEffectImpl.onRemembered(Effects.kt:87)
androidx.compose.runtime.internal.RememberEventDispatcher.dispatchRememberList(RememberEventDispatcher.kt:268)
androidx.compose.runtime.internal.RememberEventDispatcher.dispatchRememberObservers(RememberEventDispatcher.kt:240)
androidx.compose.runtime.CompositionImpl.applyChangesInLocked(Composition.kt:1209)
androidx.compose.runtime.CompositionImpl.applyChanges(Composition.kt:1236)
androidx.compose.runtime.Recomposer.composeInitial$runtime(Recomposer.kt:1211)
androidx.compose.runtime.CompositionImpl.composeInitial(Composition.kt:732)
androidx.compose.runtime.CompositionImpl.setContent(Composition.kt:699)
androidx.compose.ui.window.Application_desktopKt$awaitApplication$2$1$2.invokeSuspend(Application.desktop.kt:219)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith$$$capture(ContinuationImpl.kt:34)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt)
kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
java.awt.event.InvocationEvent.dispatch$$$capture(InvocationEvent.java:318)
java.awt.event.InvocationEvent.dispatch(InvocationEvent.java)
java.awt.EventQueue.dispatchEventImpl(EventQueue.java:781)
java.awt.EventQueue$4.run(EventQueue.java:728)
java.awt.EventQueue$4.run(EventQueue.java:722)
java.security.AccessController.doPrivileged(AccessController.java:400)
java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:87)
java.awt.EventQueue.dispatchEvent(EventQueue.java:750)
java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:207)
java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:128)
java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:117)
java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:113)
java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:105)
java.awt.EventDispatchThread.run(EventDispatchThread.java:92)
I’ve Done the Impossible - Jetpack Glance + WidgetKit = WARP
I've been working on **WARP** (**W**idget **A**bstraction, **R**endering **P**ipeline), an open-source Kotlin Multiplatform library for building Android & iOS home screen widgets from a single API.
I couldn't find any KMP widget library, so I decided to build one.
The goal is simple:
Write widget UI once in Kotlin, render it natively on:
- Android (Jetpack Glance)
- iOS (WidgetKit + SwiftUI)
A widget looks like this:
```kotlin
WarpColumn {
WarpText("Counter")
WarpRow {
WarpButton("-", onClick = CounterActions.Decrement.asClickAction())
WarpText(state.count.toString())
WarpButton("+", onClick = CounterActions.Increment.asClickAction())
}
}
```
Internally WARP works like this:
```text
Compose-like Kotlin UI
↓
WarpNode Tree
↓
JSON
↓
Android Glance / WidgetKit
```
Features so far:
- Android (Jetpack Glance) renderer
- iOS (WidgetKit + SwiftUI) renderer
- Shared click handlers
- Local assets support (Android drawables, SF Symbols, local images)
- Shared widget state
- Support Glance Premitive composables
GitHub:
https://github.com/DevAtrii/Warp
I'm planning to publish the first Maven artifacts (and Swift Package for the iOS renderer) in the next few days.
I'd really appreciate architectural feedback before I stabilize the API. If you like the project, please consider giving it a ⭐ on GitHub. It really motivates me
log4k 2.3.0 — a Kotlin IR compiler plugin that instruments your functions with tracing, logging and metrics
log4k is a coroutine/channel-based logging + tracing + metering library for Kotlin Multiplatform (JVM, Android, iOS, macOS, Linux, Windows, JS, wasmJs, wasmWasi), aligned with the OpenTelemetry model.
The recent addition is log4k-compiler-plugin — a Kotlin IR compiler plugin that rewrites annotated functions at compile time, so the instrumentation boilerplate disappears from your source. It runs on common IR before backend lowering, so the same annotations work on every KMP target — not just the JVM (no AspectJ, no bytecode agent, no reflection).
Setup — one Gradle plugin, no extra config:
plugins {
id("io.github.smyrgeorge.log4k") version "2.3.0"
}
dependencies {
implementation("io.github.smyrgeorge:log4k-classic:2.3.0")
}
@Traced — wraps the body in a span (started, ended, marked failed on throw):
@Traced
context(_: TracingContext)
suspend fun loadUser(id: Long): User {
// ...
} // span "UserService.loadUser"
The parent span is resolved from what's in scope: a TracingContext param/receiver → nests under its current span; else a TracingEvent.Span in scope → used as parent; else a trace: Tracer member (reused, or synthesized) → new root span.
@Logged — entry/exit/failure logging:
@Logged
fun compute(x: Int): Int = x * x
// → UserService.compute(x = 5)
// ← UserService.compute = 25(12.5 us)
Throwing logs ✗ UserService.compute failed (…) at ERROR with the throwable, then rethrows. If a span is in scope it's attached to every emitted line.
@Timed — call/error counters + a duration histogram:
@Timed(tags = [Tag("tier", "gold")])
suspend fun placeOrder(id: Long): Order {
// ...
}
Records OrderService.placeOrder.calls, .errors and .duration (ms histogram) — exportable in OpenMetrics line format via SimpleMeteringCollectorAppender.
Details that mattered while building it:
- suspend and regular functions are both supported; the generated wrapper delegates to
inlinehelpers (Logger.logged,Meter.Timed.measure,TracingContext.traced), so there's no per-call lambda allocation. - The plugin reuses your existing log / meter / trace members if they're thesynthesizes
private val _log_ = Logger.of( this::class)under a distinct name,so it never clashes with e.g. an existing SLF4J log. - All three annotations work class-level too — annotate the class to instrummember. Per-function annotations override the class defaults, and
@NoLog/@NoTime/@NoTraceopt out a single function or the whole class. - The metric instrument bundle is created once and cached per name.
The plugin is marked experimental — behavior and API may still change.
Repo: https://github.com/smyrgeorge/log4k
Compiler Plugin: https://github.com/smyrgeorge/log4k#compiler-plugin
Docs: https://smyrgeorge.github.io/log4k/
Feedback welcome, especially on the annotation surface and on cases where thon't pick what you'd expect.
[Library] audio-stream-player: A KMP library for playing low-latency audio, such as text-to-speech or realtime voice APIs
I built a KMP library specifically for playing audio streams, not URLs or files. With voice AI as big as it is now, handling bytes from an API is such an important topic, but KMP currently has no easy way to handle them.
That's why I built audio-stream-player. The usage is straightforward:
val player = AudioStreamPlayer(sampleRate = 24000)
player.play()
ttsResponse.collect { chunk -> player.feed(chunk) }
player.endOfStream() // suspends until the last sample has played
player.dispose()
Feed PCM chunks of any length as they arrive, and playback starts immediately and plays gaplessly. The annoying parts are handled:
- Frame alignment across chunk boundaries – API chunks rarely end on frame edges
- Resampling –
sampleRate/channels/formatdescribe your data; the device side is handled natively - Underruns – if the buffer runs dry mid-stream, playback resumes automatically when more data arrives, with an event so you can show buffering UI
endOfStream()suspends until the last sample has actually played – no guessing when the TTS utterance is done- iOS audio session configured for you (
playback/spokenAudio), opt-out if you manage it yourself
Under the hood, it uses AudioTrack in MODE_STREAM on Android and AVAudioEngine + AVAudioPlayerNode on iOS/macOS. Works on Android, iOS, and macOS.
implementation("com.adrianczuczka:audio-stream-player:0.1.0")
Repo: https://github.com/adrianczuczka/audio-stream-player-kmp
API feedback is very welcome. A web target via the Web Audio API is the likely 0.2.0 if there's interest.
Why We Chose KMP Over React Native: It’s an Architecture Bet, Not a Framework Preference
Introducing WARP — write KMP home-screen widgets once, render on Glance + WidgetKit
Building a Kotlin Multiplatform widgets library because I couldn't find one
While building my KMP apps, I needed home screen widgets on both Android and iOS.
I assumed there would already be a KMP widgets library.
There wasn't.
Since Shipaton has a category for Kotlin libraries this year, I decided to build one.
It's called WARP (Widget Abstraction Rendering Pipeline).
The goal isn't to recreate Compose or SwiftUI. The goal is to let developers describe widget UI once in Kotlin and render it natively using each platform's widget framework.
Instead of this:
Android UI → Glance
iOS UI → WidgetKit + SwiftUI
you write:
WarpColumn {
WarpText("Counter")
WarpRow {
WarpButton("-", onClick = CounterActions.Decrement.asClickAction())
WarpText(state.count.toString())
WarpButton("+", onClick = CounterActions.Increment.asClickAction())
}
}
which becomes:
Compose-like Kotlin UI
↓
WarpNode Tree
↓
JSON
↓
Android Glance / SwiftUI WidgetKit
The idea is that common code never knows about Glance or WidgetKit. It only produces a serializable tree describing the widget.
Some implementation details:
- Uses Compose Runtime only to build the tree (no Compose UI)
- Tree is fully serializable with kotlinx.serialization
- Typed click actions instead of serializing lambdas
- Shared click handlers across Android & iOS
- Native renderers consume the same JSON
- State-driven recomposition through
composeWarp(state)
Current architecture is split into:
warp-runtime
- Compose-like DSL
- Compose → WarpNode
- JSON serialization
- Action model
- State & recomposition
warp-ui
- Android Glance renderer
- iOS WidgetKit + SwiftUI renderer
- Shared click dispatch
- Swift bridge using spm4Kmp
warp-widgets
- High-level widget APIs
- Common widget definitions
- Jetpack Glance-like developer experience
Current status:
- Android renderer ✓
- iOS renderer ✓
- Shared click handlers ✓
- Counter demo ✓
- API still evolving
I'm currently looking for architectural feedback before I stabilize things.
Some questions I'm thinking about:
- Should JSON be the transport layer or should I pass the object tree directly?
- Should click handlers stay typed or become string-based?
- Is Compose Runtime the right abstraction for authoring widgets?
- What widget APIs would you expect before calling this usable?
Repository:
https://github.com/DevAtrii/Warp
I'd appreciate any feedback from people building KMP libraries or cross-platform tooling.