

Moving an Android library to Kotlin Multiplatform without breaking Android

Aardink 0.4.1 was an Android library with apps depending on it. Aardflex’s Android app is on 0.4.0. Version 0.5.0 had to add two targets, desktop JVM and the browser, without an Android user noticing.
Checks first, then changes
PR 0 moved no code. It added Roborazzi screenshot tests, a small Android app in tools/consumer-smoke/ that depends on
the published coordinate, and scripts/verify-consumer.ps1 to build it.
Every later PR had to leave the screenshots unchanged and keep the consumer app building. From PR 3 an ABI dump was added too, and it may only grow.
The checks had a hole. verify-consumer.ps1 printed the dependency tree but never asserted that aardink-android was
in it. The claim that Android consumers still resolve the Android artefact was being checked by me squinting at output.
The review caught it. The script now exits non-zero if the grep fails.
The riskiest change goes first
The editor used BasicTextField(TextFieldValue) with a VisualTransformation. Both are deprecated. Moving to
TextFieldState with InputTransformation and OutputTransformation was the biggest behaviour change in the whole
migration.
I did it in PR 1, on the Android-only tree. If something regressed, it could not be blamed on multiplatform.
It paid off straight away. A hardware Ctrl+Z went to the text field’s own undo and skipped the editor’s, so the field
and the document drifted apart. Intercepting keys in onPreviewKeyEvent fixed it, and that fix helps Android too.
What stopped the code compiling as common Kotlin
Not much, but each item was a proper job.
EditorThemeParserusedorg.json, which is Android only. I ported it tokotlinx.serialization.jsonwith the same public signature. That is the only new dependency in:editor.LspClientleaned on the JVM for concurrency:ConcurrentHashMap,CopyOnWriteArrayList,AtomicLong, five@Synchronizedmethods,@Volatile, andDispatchers.IOas a constructor default. I rewrote it onkotlinx.coroutines.sync.Mutex. I rejected atomicfu because it is a compiler plugin and runtime dependency for every consumer, andkotlin.concurrent.atomicsbecause it is still experimental.StreamLspTransportusesjava.iostreams, so it moved unchanged intojvmAndAndroidMain. The browser getsWebSocketLspTransport.- 23 test files moved from JUnit 5 to
kotlin.test. About 70runBlockingcalls becamerunTest, because wasm has norunBlocking.
The Mutex rewrite broke what @Synchronized did not
Swapping a lock is not a mechanical change. @Synchronized quietly gave me two guarantees, and Mutex gave me neither.
Cancellation. The receive loop ended with finally { onReceiveLoopEnded() }, and that function does
lock.withLock { ... }. In a cancelled coroutine, a suspending call in finally throws CancellationException before
its body runs. So when a host cancelled the shared scope, closed was never set, pending requests never failed, and the
transport never closed.
// before
} finally {
onReceiveLoopEnded()
}
// after
} finally {
withContext(NonCancellable) { onReceiveLoopEnded() }
}
sendRequest already had that fix. The loop did not. The existing test cancelled before start, so it never got near the
bug.
Ordering. Adding and removing diagnostics listeners fell back to scope.launch when the lock was busy. Two launched
coroutines run in any order, so an add then a remove could execute backwards and leak the listener. A late
publishDiagnostics could also repopulate state after didClose. @Synchronized gave program order for free.
I fixed that by taking listeners off the lock entirely. They are a @Volatile copy-on-write list, written lock-free, so
program order holds again.
The build fought back
CI was red from PR 3 to PR 5. The Android KMP plugin (com.android.kotlin.multiplatform.library) has no test or
lint task, so :editor:test and :editor:lint simply failed. Android Lint is gone for the libraries. Tests now run
as jvmTest and wasmJsBrowserTest.
The rest, in the order it hurt:
- The Compose plugin is applied to
:languagesand:languages-lsp, which have no Compose code. Wasm browser tests need the bundled Skiko runtime to run at all. Same reason forbinaries.executable()on library targets (CMP-4906). Odd, but she’ll be right. - A
buildSrcconvention plugin put the Kotlin plugin on the build classpath, and Gradle then rejected each module’s own versioned plugin alias. I deletedbuildSrc. - Dokka 2’s Markdown output: the task succeeds and writes zero files. I dropped it.
- The Kotlin daemon ran out of heap at 2 GB linking four wasm test executables. I raised it to 4 GB.