A blueprint shows one Kotlin codebase building the Aardink editor for Android, the desktop, and the browser.A blueprint shows one Kotlin codebase building the Aardink editor for Android, the desktop, and the browser.

Aardflex’s web app edits wallpaper XML in Monaco, the editor inside VS Code. On a laptop it’s great. On a phone it doesn’t work, and it was never meant to. Aardink, my Compose code editor, already does that job in the Aardflex Android app. So the question behind this series was simple: can one editor run on Android, the desktop, and the web, without a second codebase?

It can, with Kotlin Multiplatform and Compose Multiplatform. Here is why that pair and not the others.

Few editors to choose from

A code editor is a hard component. It needs colouring that keeps up with typing, folding, undo that groups edits sensibly, find and replace, and a way to talk to a language server. Few people build one, so on each platform you get what already exists.

On Android the mature editors are built on Views. Sora Editor, the most complete, is a custom View. You can host it in Compose with AndroidView, but then the editor sits outside Compose’s state and theming. The Compose options I found are mostly syntax highlighters over a text field, and some run highlight.js in a hidden WebView. That gap is why Aardink exists.

The web has more choice: Monaco, CodeMirror, and Ace. Shrink the browser to a phone and the choice narrows again. Monaco’s own FAQ is blunt: “Is the editor supported in mobile browsers or mobile web app frameworks? No.” CodeMirror 6 does run on phones, because it leans on the browser’s own text editing, but it’s a second editor in a second language, with its own grammars, its own undo, and its own bugs.

So the one editor that already worked well on a phone was Aardink, on Android. I reckoned the cheapest way to a phone-friendly web editor was to run that same code in the browser.

One codebase, native everywhere

In an ideal world you write the thing once, and every target gets a proper native build. It is compiled for that platform, it uses the platform’s input, look, and accessibility, it can reach the platform’s APIs when it has to, and nothing changes for the users you already have. No framework gives you all of that. Each one picks what to give up.

Two projects with similar names

Kotlin Multiplatform (KMP) starts from the compiler. Kotlin has a back end per target: JVM bytecode for Android and the desktop, native binaries through LLVM for iOS, and JavaScript or WebAssembly for the browser. Shared code is ordinary Kotlin compiled for each target, with no bridge or interpreter between it and the platform. Sharing is opt-in, so you can share one module or the lot. Where a platform has to differ you say so with expect and actual, rather than hiding it behind a lowest common denominator. KMP has been stable since Kotlin 1.9.20 in November 2023.

Compose Multiplatform (CMP) is the UI half: JetBrains’ Compose for the desktop, iOS, and the web, built on KMP. Its most important design choice is on Android, where it resolves to the AndroidX Compose artifacts. That is the platform’s own modern UI toolkit, so an Android app gets exactly what it had. Everywhere else CMP draws with Skia, the way Flutter does: one look on every platform, but not the platform’s own controls.

For a code editor that trade costs almost nothing. No platform has a native code editor control, and every editor, Monaco included, draws its own text.

Aardink needs both. The editor is Compose UI, so KMP alone would have shared the tokenisers and left the editor behind.

The browser build is Beta

Compose Multiplatform on the web is Beta, and so is Kotlin/Wasm under it. It draws to a canvas through Skia compiled to WebAssembly. Android, iOS, and the desktop are stable.

Compared with .NET MAUI and the rest

There are four broad ways to share one codebase, and they differ in where the shared part stops.

Shared logic, native controls, an own renderer, and a web view, each feeding a phone, laptop, and browser.Shared logic, native controls, an own renderer, and a web view, each feeding a phone, laptop, and browser.
ApproachExamplesHow each target gets its UIWhat it meant for Aardink
Shared logic onlyKMP on its ownEach platform’s own toolkitA second editor for the web
Native controls.NET MAUI, React NativeMapped to the platform’s own controlsNo code editor control to map to
Own rendererFlutterDraws every pixel itself, Android includedA rewrite in Dart
Own renderer, native on AndroidCompose MultiplatformJetpack Compose on Android, Skia elsewhereThe same code, moved
Web viewElectron, CapacitorA browser engine inside the appMonaco again, and not on phones

.NET MAUI is the closest in spirit and the furthest in design. One C# and XAML project targets Android, iOS, macOS, and Windows, and its controls go through handlers to each platform’s own controls: an Entry is an EditText on Android and a UITextField on iOS. You get each platform’s look and accessibility for free, as long as the control exists on every platform.

A code editor doesn’t exist on any of them. In MAUI you would either host Monaco in a web view, which rules phones out again, or draw the editor yourself on MAUI’s graphics canvas, which is the own-renderer approach built by hand. MAUI also has no browser target of its own. Sharing UI with the web means Blazor Hybrid: Razor components in a web view inside the app.

Flutter makes Compose Multiplatform’s choice everywhere, Android included, and in Dart. For Aardink that meant a rewrite, and an Android app built with Compose would have to embed a Flutter view to use it. React Native maps to native views the way MAUI does, and has the same missing control. A web view, Electron on the desktop or Capacitor on a phone, brings back Monaco, and on a phone that’s where this started.

Only Kotlin with Compose kept the Android editor as it was, the same Jetpack Compose code under the same Maven coordinates, and put that code in a browser.

The targets Aardink picked

  • Android, the existing product. The rule was zero regression.
  • jvm() for the desktop, the fastest loop for developing and testing, and where the Compose UI tests run.
  • wasmJs for the browser, the way to replace Monaco.

There is no iOS target. Aardflex’s app is Android only, so nothing needed it. Adding it later means writing the iOS side of the platform/ package, not a second editor.

Source sets

KMP splits each module into source sets. Shared code goes in commonMain, and each target gets its own: androidMain, jvmMain, and wasmJsMain. Tests in commonTest run on the JVM and in headless Chrome. Between the two levels you can add your own sets, for code that some targets share and others can’t use.

Aardink has two of those in-between sets. skikoMain, added in 0.6, holds the text input code that the desktop and the browser share: Compose draws both with Skia, and their text input request is one interface, different from Android’s. :languages-lsp has a hand-made jvmAndAndroidMain for StreamLspTransport, because java.io streams exist on Android and the JVM but not on wasm. The default hierarchy template doesn’t create that set for you.

expect and actual

Aardink has one rule here: expect and actual live only in the platform/ package. Everything else is common code, and UI code reads platform facts rather than testing for a platform itself.

EditorDispatchers is the clearest example. The common declaration says what the editor needs:

// commonMain
expect object EditorDispatchers {
    val compute: CoroutineDispatcher
    val io: CoroutineDispatcher
    val computeIsMainThread: Boolean
}

On Android and the desktop compute is Dispatchers.Default and io is Dispatchers.IO. The browser is different:

// wasmJsMain
actual object EditorDispatchers {
    actual val compute: CoroutineDispatcher = Dispatchers.Default
    actual val io: CoroutineDispatcher = Dispatchers.Default
    actual val computeIsMainThread: Boolean = true
}

Dispatchers.IO doesn’t exist on wasm, and Dispatchers.Default there is the one JavaScript event loop. So computeIsMainThread is true, and long work such as tokenising a big file has to be chunked, or the page freezes.

PlatformInfo is the second example: isMacOs, hasSoftKeyboard, and where the keyboard toolbar goes. On the web WindowInsets.ime is always 0, so a toolbar that hovers above the soft keyboard would never appear there.

What Android users see

The dependency line didn’t change:

implementation("com.aardarch:aardink:0.5.0")

Publishing does more behind it. There is now a root publication with Gradle Module Metadata, plus one artifact per target: aardink-android (an .aar), aardink-jvm (a .jar), and aardink-wasm-js (a .klib). Gradle reads the metadata and picks the Android one, the same way it does for kotlinx.coroutines. Only a build that pinned the .aar explicitly has anything to change.