

Moving an Android library to Kotlin Multiplatform without breaking Android

Outline
Notes and sources, not prose. Rewrite each section before publishing. Sources are in the aardink repo unless noted. If this runs long, split “The build fought back” into its own post.
TODO: opening. Aardink 0.4.1 was an Android library with apps depending on it (aardflex’s Android app is on 0.4.0). 0.5.0 had to add two targets without an Android user noticing.
Checks first, then changes
- PR 0, before any code moved: Roborazzi screenshot tests committed,
tools/consumer-smoke/(an Android app that depends on the published coordinate) andscripts/verify-consumer.ps1. - Every later PR: screenshots unchanged, the consumer app builds, and from PR 3 an ABI dump that may only grow.
- Real failure:
verify-consumer.ps1printed the dependency tree but never asserted thataardink-androidwas in it. The review caught it (KMP_MIGRATION_REVIEW.mdC7).
The riskiest change goes first
- The editor used
BasicTextField(TextFieldValue)with aVisualTransformation, which is deprecated. Moving toTextFieldStatewithInputTransformationandOutputTransformationwas the biggest behaviour change. - Done in PR 1 on the Android-only tree, so a regression there could not be mistaken for a multiplatform problem.
- Side fix: a hardware Ctrl+Z went to the text field’s own undo and skipped the editor’s, so field and document drifted
apart. Intercepting keys in
onPreviewKeyEventfixed that on Android too. - Source:
KMP_MIGRATION_PLAN.mdsections 4 and 5.1.
What stopped the code compiling as common Kotlin
org.jsoninEditorThemeParser, Android only. Ported tokotlinx.serialization.jsonwith the same public signature. The only new dependency in:editor.LspClient:ConcurrentHashMap,CopyOnWriteArrayList,AtomicLong, five@Synchronizedmethods,@Volatile, andDispatchers.IOas a constructor default. Rewritten onkotlinx.coroutines.sync.Mutex. Rejected atomicfu (a compiler plugin and runtime dependency for every consumer) andkotlin.concurrent.atomics(still experimental).StreamLspTransportusesjava.iostreams, so it moved unchanged intojvmAndAndroidMain. The browser getsWebSocketLspTransport.- 23 test files moved from JUnit 5 to
kotlin.test, and about 70runBlockingcalls becamerunTest, because wasm has norunBlocking. - Source:
KMP_MIGRATION_PLAN.mdsections 3 and 8.
The Mutex rewrite broke what @Synchronized did not
- A
finallyblock calledlock.withLock, which throwsCancellationExceptionin a cancelled coroutine before the body runs. Cancel the host’s scope and the transport never closed and pending requests never failed. Fix:withContext(NonCancellable). - Adding and removing listeners fell back to
scope.launchwhen the lock was busy, so they could run out of order.@Synchronizedgave program order for free. - TODO: before and after snippet. Point: swapping a lock is not a mechanical change.
- Source:
KMP_MIGRATION_REVIEW.mdB1 to B3.
The build fought back
- CI was red from PR 3 to PR 5. The Android KMP plugin (
com.android.kotlin.multiplatform.library) has notestorlinttask, so:editor:testand:editor:lintsimply failed. Android Lint is gone for the libraries. - The Compose plugin is applied to
:languagesand:languages-lsp, which have no Compose code, because wasm browser tests need the bundled Skiko runtime to run at all. Same reason forbinaries.executable()on library targets (CMP-4906). - A
buildSrcconvention plugin put the Kotlin plugin on the build classpath, and Gradle then rejected each module’s own versioned plugin alias.buildSrcwas deleted. - Dokka 2’s Markdown output: the task succeeds and writes zero files. Dropped.
- The Kotlin daemon ran out of heap at 2 GB linking four wasm test executables. Raised to 4 GB.
- Source:
KMP_MIGRATION_PLAN.mdsections 2.4a, 2.4b and 2.5.