<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Kotlin on Trevor's Blog</title><link>http://mings.in/en/tags/kotlin/</link><description>Recent content in Kotlin on Trevor's Blog</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Fri, 23 Oct 2026 14:31:26 +0800</lastBuildDate><atom:link href="http://mings.in/en/tags/kotlin/index.xml" rel="self" type="application/rss+xml"/><item><title>AI Can Write Swift and Kotlin Now. So What's the Point of Cross-Platform?</title><link>http://mings.in/en/posts/flutter-ai-scale/</link><pubDate>Fri, 23 Oct 2026 14:31:26 +0800</pubDate><guid>http://mings.in/en/posts/flutter-ai-scale/</guid><description>On September 10, Shopify&amp;rsquo;s head of mobile published a post.</description><content:encoded><![CDATA[<p>On September 10, Shopify&rsquo;s head of mobile, Mustafa Ali, published a post.</p>
<p>The title was blunt: native is now the future of mobile at Shopify.</p>
<p>Six years ago they went all-in on React Native. Last January, Ali wrote a retrospective saying RN had a bright future. Now they are reversing course, and the stated reason is one thing: coding agents have crushed the cost of writing everything twice.</p>
<p>The switch worked out. Six engineers took Shop App from proof of concept to the app store in 12 weeks. iOS cold start improved 23%, Android 50%.</p>
<p><img alt="Migration diagram from Shopify&rsquo;s engineering blog" loading="lazy" src="/images/flutter-ai-scale/shopify-native.png"></p>
<p>But the most valuable line in that post is not a number.</p>
<p>Ali writes that an agent can rewrite the code in seconds, then spend minutes working out whether it actually works. Simulator control is too slow, so getting state means going through the accessibility tree or taking screenshots.</p>
<p>His words: if the model cannot quickly verify its own work, it does not matter how good the model is.</p>
<p>That complaint made me read two posts side by side. The Shopify piece from September 10 and the Flutter team&rsquo;s response forty days later are arguing about the same thing.</p>
<h2 id="the-bottleneck-is-not-generation-it-is-verification">The bottleneck is not generation, it is verification</h2>
<p>The Flutter post went up on October 20, by Carlos Mendonça and Emma Twersky. Its title is just as blunt: AI makes it easy to migrate, Flutter makes it easy to scale. It exists to answer the question Shopify threw down.</p>
<p>The whole piece rests on one claim. Code generation stopped being the bottleneck a while ago. Verification is the bottleneck now.</p>
<p>Two numbers back it up. 73% of developers say AI makes them more productive when starting a task. 46% do not trust AI&rsquo;s debugging accuracy without a human checking it.</p>
<p>Squeeze from both sides and the blocker sits on the human side.</p>
<h2 id="the-bill-for-three-steps">The bill for three steps</h2>
<p>The team published a comparison table putting native and Flutter next to each other across the three verification steps.</p>
<p><img alt="The same table rearranged in Chinese, values and wording unchanged" loading="lazy" src="/images/flutter-ai-scale/verify-table.png"></p>
<p>Code-to-running latency: native takes 2 to 5 minutes per cycle. Build, link, archive, install on the simulator. Four debug rounds burn twenty-odd minutes.</p>
<p>Flutter stays under 200 milliseconds. Hot reload injects the change into a running Dart VM and keeps state.</p>
<p>Automated tests: native walks the full simulator lifecycle and a background emulator. Flutter&rsquo;s headless widget tests run in an isolated environment in under 500 milliseconds.</p>
<p>The UI and state-check row is where it gets interesting.</p>
<p>Native screenshots the simulator and hands the image to a vision model to guess at the layout. Probabilistic, and slow. Flutter goes through MCP to query the Dart VM Service and DevTools, reading the widget and render object trees directly. Deterministic, structured.</p>
<p>Chain the three and you have a complete loop. Every step queues on the native side. On Flutter a round trip is milliseconds.</p>
<h2 id="harness-beats-prompt">Harness beats prompt</h2>
<p>Fast verification is one thing. Getting the agent to actually use it is another. The team ran an internal experiment called Project One Shot, letting an agent fix a production bug in camera_android_camerax on its own.</p>
<p>The plugin manages Android CameraX lifecycles and surface texture binding, the hairiest kind of Flutter code there is.</p>
<p>Given vague instructions like watch out for memory leaks, the agent failed every time.</p>
<p>It only worked with a deterministic harness. AST analysis over MCP, package-level domain skills, enforced test-driven guardrails. Reproduce with a failing test, write the smallest fix, finish with the full test suite and leak checks.</p>
<p>No room for the agent to freestyle. Every step has its acceptance criteria nailed down.</p>
<p>Three things shipped alongside. An official Dart and Flutter agent plugin bundles an MCP server with ten Flutter and fourteen Dart skills, so the agent can reach the Dart Analyzer and Widget Inspector directly.</p>
<p>A dedicated agent inside Antigravity audits accessibility issues and submits fixes that match existing conventions. Plus an Agent Skills CLI built with Serverpod: one dart run skills@get installs the skills a package ships with into your agent.</p>
<p>The benchmark numbers: Gemini 3.5 Flash from 84.1% to 85.5%, Gemini 3.1 Pro from 89.6% to 92.7%. Modest gains, run by the team on its own eval suite.</p>
<h2 id="where-this-actually-lands">Where this actually lands</h2>
<p>The post lists a set of deployment targets well beyond phones. Toyota and Lexus head units, the Xiaomi SU7 center console, Nubank&rsquo;s fintech app with a hundred million users, Universal Music.</p>
<p>The post does not push this angle. I think it is the most convincing part.</p>
<p>What these share is absurdly expensive verification.</p>
<p>Flashing a head unit takes tens of minutes. One production incident in a fintech app costs real money. Music app users tolerate zero jank.</p>
<p>In places like these, a minute off the verification loop is money and time you keep.</p>
<p>Look further out. Once agents writing code is normal, one codebase across platforms saves more than headcount. It saves time.</p>
<p>An agent produces a change, hot reload shows it on phone, desktop and web at once. That is an order of magnitude faster than compiling, packaging and installing per platform.</p>
<p>That is also why the Flutter team has been pushing into automotive, desktop and embedded.</p>
<p>The second half of cross-platform may not happen on phones.</p>
<h2 id="a-few-things-i-do-not-buy">A few things I do not buy</h2>
<p>The app store rating section does not hold up. It cites Apptopia&rsquo;s study of 201,000 apps. On Google Play, Flutter apps average 4.1 stars, and so do non-Flutter apps. On the App Store both sit at 4.0. The conclusion offered is that users do not care about the framework.</p>
<p>All that shows is that cross-platform did not hurt ratings. Flip it around and it also shows cross-platform did not help. Using a tie to argue for adoption is missing a step.</p>
<p>That 200 milliseconds is the time to inject a change. It excludes writing the code, thinking it through, switching windows.</p>
<p>It cannot change class structure either. Add a field or restructure the state tree and you need a restart.</p>
<p>The table measures code-to-running latency, which is an honest definition. Readers will take it as development being twenty times faster, and those are very different claims.</p>
<p>The enterprise examples have survivorship bias too.</p>
<p>Xiaomi SU7, Universal cutting 45% of its code, Toyota and Lexus, Nubank&rsquo;s hundred million users. All real.</p>
<p>No post ever covers the company that moved to cross-platform for two years and then tore it out. Arguments from these examples are half evidence.</p>
<p><img alt="The eight arguments the team published, enough to argue with in a team meeting" loading="lazy" src="/images/flutter-ai-scale/talking-points.png"></p>
<h2 id="how-to-do-the-cross-platform-math">How to do the cross-platform math</h2>
<p>Line up both sides first. Shopify&rsquo;s call: the cost of writing twice has been crushed, and what is left is not worth giving up native capabilities for. Flutter&rsquo;s call: coordination costs remain.</p>
<p>The example in the post is specific. Same checkout state machine, two instances of the same model. One writes Swift and drops stale events through an async stream. One writes Kotlin and queues events through a reactive flow.</p>
<p>Both pass local unit tests on day one. Weeks later Android checkout conversion drops three points, because under network jitter the retry timing differs.</p>
<p><img alt="From the original post: parallel agents do not remove coordination cost, they introduce nondeterministic drift" loading="lazy" src="/images/flutter-ai-scale/parallel-agent-challenge.png"></p>
<p>Both positions hold. They are doing different arithmetic.</p>
<p>I also do not think this pivot is evidence that cross-platform is dead.</p>
<p>This is a company with hundreds of screens, over a thousand engineers, and mobile as a core investment. At that scale, rebuilding for 23% faster cold start and a better crash rate pencils out.</p>
<p>For five people maintaining a mid-sized app, the same move probably does not.</p>
<p>What decides whether you go cross-platform is team size, how many years you will maintain it, and how long your verification chain is. AI changes none of those.</p>
<p>Here is a question worth arguing about instead. If agents really do get a verification cycle down to a few hundred milliseconds, how much longer does native-feels-better hold up.</p>
<p>I would like to hear what is actually on your desk. Is your project single-platform, dual, or cross? What drove that call: performance, headcount, or a boss who said we are going native?</p>
<p>One more question: do you think Compose Multiplatform has a shot? It is cross-platform too, arrived years after Flutter, but it stands on half the Kotlin and Android ecosystem.</p>
<p>Drop a comment. I will pick a few representative ones and write them up as a follow-up.</p>
<p>Both source posts are here. Twenty minutes total.</p>
<p><a href="https://shopify.engineering/back-to-native">Native is now the future of mobile at Shopify</a></p>
<p><a href="https://flutter.dev/blog/ai-makes-it-easy-to-migrate-flutter-makes-it-easy-to-scale">AI makes it easy to migrate. Flutter makes it easy to scale.</a></p>
]]></content:encoded></item><item><title>Kotlin 2.5 Changes Its Publishing Format, and the Compiler Gets Stricter</title><link>http://mings.in/en/posts/kotlin25/</link><pubDate>Thu, 22 Oct 2026 07:33:31 +0800</pubDate><guid>http://mings.in/en/posts/kotlin25/</guid><description>On October 21, Kotlin 2.5.0-Beta2 shipped. Most people open the notes, read the four promoted and two experimental language features, decide it is unremarkable, and close the tab.</description><content:encoded><![CDATA[<p>On October 21, Kotlin 2.5.0-Beta2 shipped. Most people open the release notes, read the language feature lines: four promoted, two experimental. Then they decide this version is unremarkable and close the tab.</p>
<p>The real changes are in the later sections. The publishing format changed, the compilation scheme changed, and three defaults were quietly tightened. I installed both compilers on the same machine and fed each one the same source. The results differ.</p>
<h2 id="the-point-of-this-release-is-not-syntax">The point of this release is not syntax</h2>
<p>Two of the four stable features are really about turning a choice into a default: the syntax-only mode for destructuring by name, and the unused return value checker. Not one line of old code needs changing. What new code can express changes.</p>
<p>The two experimental ones follow the same line: companion extensions and companion blocks, and full value classes. All of them remove a bit of arbitrary restriction from the language.</p>
<p>The language layer is the quiet part this time. Laid out properly, the substance is in the last three sections of the doc: the Kotlin Multiplatform publishing format, the new compilation scheme, and incremental compilation on Apple platforms. None of those change what you write. They change what you ship and how long it takes to build.</p>
<h2 id="one-kar-file-replacing-seven-thousand">One .kar file replacing seven thousand</h2>
<p>Their words:</p>
<blockquote>
<p>The new format publishes platform artifacts (except for JVM and Android) as a single .kar archive, which is a ZIP archive additionally compressed with XZ.</p>
</blockquote>
<p>A library supporting several targets used to publish several sets of artifacts. Now everything except JVM and Android goes into a single <code>.kar</code>, which is a ZIP with an extra layer of XZ on top.</p>
<p>The numbers are clearer than the description. Their example is cryptography-kotlin: published artifacts went from 7,539 files and 91 MB to 1,036 files and 23 MB. For the whole multi-module project.</p>
<p><img alt="The release notes text, with that set of numbers boxed in red" loading="lazy" src="/images/kotlin25/fig1_kar.png"></p>
<p>One line to turn it on. Per module in the build script, project-wide in <code>gradle.properties</code>:</p>
<pre tabindex="0"><code>kotlin.publicationFormat=KOTLIN_ARCHIVE
</code></pre><p>Two caveats have to be read together. Except JVM and Android: those two ecosystems still use standard Maven artifacts. The consumer side is fully available, so when a library you depend on switches format, your build does not move. The publishing side is still experimental, so if you publish libraries yourself, verify your pipeline separately.</p>
<h2 id="why-maven-centrals-limit-is-about-file-count">Why Maven Central&rsquo;s limit is about file count</h2>
<p>The notes only say Maven Central recently introduced a publishing size limit. What sits behind that sentence is more interesting than size.</p>
<p>What blocks library authors is not total bytes, it is file count. A library with six targets, one artifact set per target, plus source jars and metadata, lands in the thousands without trying. Every file is a separate record in the repository, a separate upload, a separate index entry. For the repository that is operational cost. For the user it is the first screen of requests during dependency resolution.</p>
<p>So 7,539 files down to 1,036, minus eighty-six percent, matters more than 91 MB going to 23 MB. What you save is requests and index entries, not bandwidth.</p>
<p>JVM and Android being excluded says something too: those two ecosystems are still anchored to standard Maven classifiers and cannot move. The real situation for cross-platform libraries is many targets, mainline platforms on the old format, everything else in an archive. That hybrid will stick around for a while.</p>
<p>The change is completely transparent downstream, and because it is transparent, it can be recommended to everyone. The beneficiaries are library authors and the people maintaining release pipelines, not those of us writing application code.</p>
<h2 id="the-new-compilation-scheme-common-code-should-not-quietly-reach-platform-apis">The new compilation scheme: common code should not quietly reach platform APIs</h2>
<p>This section is written with restraint. Four items: make the compiler treat common source sets the same way the IDE does; prevent common code from accidentally resolving to platform-specific declarations; improve consistency of overload resolution and type inference; enable incremental compilation for common source sets.</p>
<p>The first three fix an old hole. One wrong import in <code>commonMain</code> and the IDE marks it red while the command line passes, or the reverse, and the two never agree. The cause: the IDE and the compiler each have their own resolver, each doing its own math.</p>
<p>Unifying them has a side effect: code that used to compile may not anymore. That is not a regression. That code was reaching symbols it should not have seen. Nobody was stopping it.</p>
<p>The fourth is the everyday payoff. Common code finally gets incremental compilation, and people who live in the IDE feel it most. On CI you will not notice, because the pipeline builds from scratch every time.</p>
<p>JetBrains wrote a dedicated post on this scheme in September, with the word reliable in the title. The problem being fixed is exactly unreliability: the IDE and the compiler reaching two different conclusions about the same common code.</p>
<h2 id="the-compiler-got-stricter-three-defaults-changed">The compiler got stricter: three defaults changed</h2>
<p>The most visible one first. The same code fails to compile on 2.4.21 and passes by default on 2.5.0-Beta2:</p>
<pre tabindex="0"><code>(val mail = email, val name = username) = user
</code></pre><p><code>val</code> inside the parens, an equals sign picking which property to take, and the variable name no longer has to match the property name.</p>
<p><img alt="All three features on my machine" loading="lazy" src="/images/kotlin25/fig3.png"></p>
<p>An old hole while we are here. Positional destructuring like <code>val (email, username) = user</code> passes on both versions, but the values are assigned by position. I destructured <code>User(username, email)</code> into <code>email=alice</code>, <code>username=alice@example.com</code>. Every variable name wrong, and the compiler said nothing. This feature exists because of that.</p>
<p>Second, the unused return value checker. The same file passes silently on 2.4.21 and warns on 2.5.0-Beta2: <code>unused return value of 'plus'</code>. If an expression produces a non-Unit value and nobody uses it, it tells you.</p>
<p>Third, cross-module inlining. Optional when introduced in 2.4.0, on by default in 2.5.0-Beta2. The off switch is <code>-Xklib-ir-inliner=disabled</code>.</p>
<p>There is one more that is not a default. Type inference generates fewer constraints and no longer stalls on complex generics. The notes are restrained, saying only that it may change inference results, overload selection and diagnostics. In plain terms: after upgrading, some code will infer different types, and that code is code you previously believed was correct. Off switch: <code>-XXLanguage:-EliminateSecondKindIncorporation</code>.</p>
<h2 id="value-class-is-where-the-cut-goes-deepest">value class is where the cut goes deepest</h2>
<p>Value classes used to allow exactly one property and required <code>@JvmInline</code>. Multiple properties work now, plus two new shapes: abstract value classes and value objects.</p>
<p>On my machine: 2.4.21 rejects a multi-property value class outright, telling you to enable <code>-XXLanguage:+FullValueClasses</code>. 2.5.0-Beta2 passes with <code>-Xfull-value-classes</code>.</p>
<p>Same for value objects. 2.4.21 says <code>modifier 'value' is not applicable to 'standalone object'</code>; 2.5 compiles and runs with the flag.</p>
<p>One detail explains it better than the docs. Write <code>===</code> on a value object and the compiler errors immediately. Identity comparison is banned by the language, because it has no identity to compare.</p>
<p>The real trap is migration. <code>value class</code> is no longer inlined by default. The stated timeline: warnings start in 2.5.20, errors in 2.6 or 2.7. On 2.5.0-Beta2 the switch is not even recognized yet; the compiler answers that the flag is not supported, which means the window really does open at 2.5.20.</p>
<p>Meaning: upgrading today will not blow up. But you have until 2.5.20 to mark the old code, deciding what keeps inlining and what lets go. Whether a value class wrapping a single String should inline is a trade-off to begin with, and the language should not make that default for everyone.</p>
<h2 id="the-standard-library-closes-two-small-gaps">The standard library closes two small gaps</h2>
<p>Three functions landed for <code>if</code> expressions: <code>onTrue()</code>, <code>onFalse()</code>, <code>ifOrNull()</code>. Printing a line when an add succeeds used to mean a local variable and an if/else. Now one line holds it:</p>
<pre tabindex="0"><code>val added = tags.add(&#34;wasm&#34;).onTrue { println(&#34;Tag added&#34;) }
</code></pre><p>The return value is still the same boolean. The check and the side effect sit on one chain, and you no longer build a variable for a case this small.</p>
<p>The other half is Unicode code points. A <code>Char</code> holds one UTF-16 code unit, so when you hit a surrogate pair like an emoji, iterating by <code>Char</code> splits one character in half. The new <code>CodePoint</code> is a value class holding the code point in an Int, and it can tell you how many UTF-16 units it occupies, which category it belongs to, whether it is a letter.</p>
<p>Both need <code>@OptIn(ExperimentalStdlibApi::class)</code>. Note the shape of <code>CodePoint</code>: it is only writable because value classes were relaxed. Language and standard library line up in this release.</p>
<h2 id="two-surprises-on-my-machine">Two surprises on my machine</h2>
<p><img alt="The same code through both compilers" loading="lazy" src="/images/kotlin25/fig2.png"></p>
<p>First surprise: compilation did not get faster. On a 489-line project, warmed up, 2.4.21 has a median of 2.86 seconds and 2.5.0-Beta2 comes in at 2.99. The output is 161 KB either way. The difference sits in the noise, so it is not slower, but there is no visible payoff from the inference work on a small project. That kind of optimization only shows up in large generic-heavy code.</p>
<p>Second: the scope of that switch. <code>-Xklib-ir-inliner=disabled</code> is rejected outright by the JVM compiler, which says the flag is not supported. It only means something where klib is produced: Native, JS, Wasm.</p>
<p>On by default in the docs has to be read per platform.</p>
<h2 id="three-things-to-do-before-upgrading">Three things to do before upgrading</h2>
<p>The JDK floor moved. The compiler&rsquo;s minimum went from 8 to 17, and anything below now errors out. CI images and Docker base images have to follow, and that step usually eats more time than the code changes.</p>
<p>If you publish Kotlin Multiplatform libraries, do not rush into <code>.kar</code>. The publishing side is still experimental. Try it in a pipeline, do not push it to a real release.</p>
<p>Run your tests. The unused return value check and the inference changes both fall into the passes silently but behaves differently category. Reading the docs will not reveal the problem; running tests will.</p>
<h2 id="last">Last</h2>
<p>Most of what 2.5 does lands on defaults: packaged by default, checked by default, inlined by default, destructured by name by default. Every one of those comes with an off switch, which says the team knows it will step on people, and also that these defaults are not final.</p>
<p>Do you have code the IDE marks red while the command line compiles it fine? After 2.5 it will probably have to come clean. Tell me what broke on your end in the comments.</p>
<p>Sources:
<a href="https://kotlinlang.org/docs/whatsnew-eap.html">Kotlin 2.5.0-Beta2 release notes</a>
<a href="https://kotlinlang.org/docs/releases.html">Kotlin release schedule</a>
<a href="https://blog.jetbrains.com/kotlin/2026/09/a-more-reliable-compilation-scheme-for-kotlin-multiplatform-modules/">A more reliable compilation scheme for Kotlin Multiplatform modules</a></p>
]]></content:encoded></item><item><title>Amper Got Promoted: It Is Now the Kotlin Toolchain</title><link>http://mings.in/en/posts/kotlin-toolchain/</link><pubDate>Wed, 07 Oct 2026 11:52:28 +0800</pubDate><guid>http://mings.in/en/posts/kotlin-toolchain/</guid><description>On October 6, JetBrains&amp;rsquo; Amper blog published its final post, titled Amper Is Now the Kotlin Toolchain. The name carries a line underneath, Moving to the Kotlin Blog, and it means what it says: that blog stops updating as of that day and merges into the Kotlin blog.</description><content:encoded><![CDATA[<h2 id="first-this-weeks-easily-missed-announcement">First, this week&rsquo;s easily-missed announcement</h2>
<p>On October 6, JetBrains&rsquo; Amper blog published its final post, titled Amper Is Now the Kotlin Toolchain. The name carries a line underneath, Moving to the Kotlin Blog, and it means what it says: that blog stops updating as of that day and merges into the Kotlin blog.</p>
<p>What got renamed is not the blog. It is Amper itself. It is now called Kotlin Toolchain, and it is the official Kotlin toolchain.</p>
<p>News like this is easy to scroll past as another rename to dress up the quarter. But I went through the code and docs, and this one is different: they are scrubbing the word Amper out of the codebase, layer by layer.</p>
<p>How thoroughly? The 0.13 release notes make it clear. Two of the breaking changes: the environment variable AMPER_BUILD_DIR becomes KOTLIN_TOOLCHAIN_BUILD_DIR, and the module layout entry named amper becomes default.</p>
<p>When a name gets scrubbed all the way from config files down to environment variables, it is not a marketing move.</p>
<h2 id="three-moves-in-three-months">Three moves in three months</h2>
<p>I put the timeline in order:</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/timeline.png"></p>
<p>The repo moved in May, the promotion was announced in June, and the name was scrubbed out of the code in October. You can see the first two in the news. The third only shows up if you open the 0.13 release notes.</p>
<p>One line from the June post stuck with me, explaining why the name changed:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">As the project evolved, it became clear that the ecosystem doesn&#39;t just need
</span></span><span class="line"><span class="cl">another build tool – it needs a unified entry point into all of Kotlin.
</span></span></code></pre></div><blockquote>
<p>Translation: as the project moved forward, one thing became clear — this ecosystem does not need another build tool, it needs a single entry point into all of Kotlin.</p>
</blockquote>
<h2 id="i-installed-it-on-this-machine">I installed it on this machine</h2>
<p>Rather than trust what the docs say, look at how it actually runs. Installation uses the official script. I did not pipe it straight into a shell; I pulled it down and read it first. It does a SHA256 check, and the thing is reasonably well behaved.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">curl -fsSL -o /tmp/kt-install.sh https://kotl.in/install.sh
</span></span></code></pre></div><p>By default it appends a PATH line to your shell config. I skipped that with its own switch so it would not touch my zshrc:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">KOTLIN_CLI_NO_MODIFY_PATH=1 sh /tmp/kt-install.sh
</span></span></code></pre></div><p>After install:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Kotlin Toolchain version 0.13.0 (abc7a5f, 2026-10-01)
</span></span><span class="line"><span class="cl">Installed Kotlin CLI to /Users/trevorwang/.local/bin/kotlin
</span></span></code></pre></div><p>Then create a project. It has a set of non-interactive templates; I picked the JVM command line one:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">kotlin init --from-template=jvm-cli --no-git
</span></span></code></pre></div><p>Generated in 0.34 seconds. I expected a pile of files to read; the entire project config is one line:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">product</span><span class="p">:</span><span class="w"> </span><span class="l">jvm/app</span><span class="w">
</span></span></span></code></pre></div><p>Three source files: a println in main.kt, an object in World.kt, two tests in the test directory.</p>
<h2 id="build-run-and-test-all-of-it">Build, run and test, all of it</h2>
<p>On first build it pulled down a Temurin JDK 25 itself, and after that everything came up green. Install, create, compile, run and test in one pass looks like this:</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/terminal.png"></p>
<p>The whole thing took 30.8 seconds, and subtracting the JDK download the rest is fast. The test section is red because the template deliberately includes an assertTrue(false) in a function named shouldFail. My guess is it wants you to see what failing output looks like, and to note that the exit code is 1.</p>
<p>Another command I like: kotlin show settings. It prints the effective settings for each module and marks defaults with # default:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">compose:
</span></span><span class="line"><span class="cl">  version: 1.12.1  # default
</span></span><span class="line"><span class="cl">junit: junit-5  # default
</span></span></code></pre></div><p>What you wrote yourself and what it defaulted to are instantly distinguishable.</p>
<h2 id="three-lines-of-yaml-four-platforms">Three lines of YAML, four platforms</h2>
<p>A single JVM target is not much to look at, so I created another from the kmp/lib template. The full module.yaml it gave me, plus the build results per platform, look like this:</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/yaml-platforms.png"></p>
<p>Platform-specific sources do not use Groovy&rsquo;s source set naming. It is a directory suffix: src@jvm, src@android, src@ios, each holding its own actual. The shared expect goes in src.</p>
<p>You can also build only selected platforms:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">kotlin build -p jvm -p android
</span></span></code></pre></div><p>11.3 seconds, four green lines, output is kt-kmp-jvm.jar and kt-kmp-jvm.aar.</p>
<p>Here is a detail I did not expect. Poking through its download cache, I found that to compile Android it dropped a platform package into my Android SDK, with a timestamp matching this build:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">~/Library/Android/sdk/platforms/android-37.0/            11:35
</span></span><span class="line"><span class="cl">~/Library/Android/sdk/platforms/06259a9c8a-platform-37.0_r02.zip.flag
</span></span></code></pre></div><p>Add the Temurin JDK, the JUnit runner for tests, and Kotlin/Native for iOS — it pulled all of it. It is almost excessively friendly to newcomers: one install command, zero configuration, and JVM and Android compile. The cost is waiting on downloads during the first build, and it writes into your SDK directory.</p>
<h2 id="the-ios-leg-stops-at-xcode">The iOS leg stops at Xcode</h2>
<p>Add iOS and see:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">kotlin build -p iosArm64
</span></span><span class="line"><span class="cl">Downloading .../kotlin-native-prebuilt-2.4.20-macos-aarch64.tar.gz
</span></span><span class="line"><span class="cl">ERROR: Xcode first-launch setup could not be completed.
</span></span><span class="line"><span class="cl">       Run `sudo xcodebuild -runFirstLaunch` and try again.
</span></span><span class="line"><span class="cl">ERROR: Task &#39;xcodeEnvironment&#39; failed: Xcode environment is not ready
</span></span></code></pre></div><p>It downloads Kotlin/Native 2.4.20 first, then stops at that step. The check is new in 0.13; the release notes describe it as Check and/or provision Xcode components automatically.</p>
<p>The interesting part is here: Kotlin/Native itself compiles fine on this machine. I had built an iOS arm64 framework with it directly before, output in 9.8 seconds. What the toolchain checks is whether Xcode&rsquo;s components have finished first-launch installation, which sits ahead of the compiler&rsquo;s own requirements, and that step needs root, so you run the sudo command yourself.</p>
<p>I think this behavior is good. It does not compile halfway and then dump a baffling link error on you. It stops at the prerequisite check and prints the command you need.</p>
<h2 id="two-places-the-name-has-not-been-cleaned-out">Two places the name has not been cleaned out</h2>
<p>First, the distribution download URL is still the old one, with amper in it: packages.jetbrains.team/maven/p/amper/amper.</p>
<p>The second is funnier. One entry in the Android fix list says it used to install the Android SDK under a non-canonical name, and that name was Amper-33.</p>
<p>Repo, blog, command line, environment variables, module layout — cleaned all the way through, with corners still left. Details like these are more honest than a press release; they show you how many layers a rename really touches.</p>
<h2 id="on-the-ide-side-there-is-a-new-option">On the IDE side, there is a new option</h2>
<p>That covers the CLI. The IDE side changed just as visibly.</p>
<p>The plugin is listed in the Marketplace as Kotlin Toolchain, version 262.10968.205. Its first line of self-description:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Provides support for Kotlin Toolchain, JetBrains&#39; build tool for Kotlin and
</span></span><span class="line"><span class="cl">Java projects.
</span></span></code></pre></div><blockquote>
<p>Translation: provides support for Kotlin Toolchain, JetBrains&rsquo; build tool for Kotlin and Java projects.</p>
</blockquote>
<p><img loading="lazy" src="/images/kotlin-toolchain/plugin-marketplace.png"></p>
<p>Three things in the feature list I care about: running and debugging JVM, Kotlin Multiplatform and native tests; managing multi-module projects and version catalogs; and hot reload for Compose Desktop and Compose Multiplatform. There is also a note at the top of the page saying some features require an Ultimate subscription.</p>
<p>The really interesting part is the new project dialog. The Build system field is no longer just Gradle; there are two side by side, and the one on the right is Kotlin Toolchain.</p>
<p>It writes one line for each. Verbatim:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Gradle — Flexible and backed by a mature plugin ecosystem
</span></span><span class="line"><span class="cl">Kotlin Toolchain — Easy to use, declarative, and AI-friendly
</span></span></code></pre></div><blockquote>
<p>Translation: Gradle is flexible and backed by a mature plugin ecosystem; Kotlin Toolchain is easy to use, declarative and AI-friendly.</p>
</blockquote>
<p><img loading="lazy" src="/images/kotlin-toolchain/new-project.png"></p>
<p>AI-friendly is their own word. An IDE company putting &ldquo;AI-friendly&rdquo; in the default copy of its new project dialog is not a surprise this year, but the placement says a lot.</p>
<p>The platform picker follows the same idea as the CLI templates: five checkboxes for Android, iOS, Desktop, Web and Server, and for iOS and Web you can choose whether the UI is shared with the other targets. Check Android plus iOS and the structure comes out like this:</p>
<p><img loading="lazy" src="/images/kotlin-toolchain/project-structure.png"></p>
<p>A project.yaml at the root lists the modules: androidApp, desktopApp, iosApp, shared, webApp, core, server — seven. Each module has its own module.yaml, the same three-line thing from the CLI earlier. Versions still live in libs.versions.toml; it kept that Gradle habit, and the wrapper scripts are called kotlin and kotlin.bat.</p>
<p>Run configurations work as is: pick iPhone 17 | iOS 27.0 in the device dropdown and iosApp runs.</p>
<h2 id="so-should-you-switch">So should you switch</h2>
<p>The official FAQ has a passage that answers why they did not just improve Gradle. Verbatim:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">With the Kotlin Toolchain, we want to show you our design and get your feedback,
</span></span><span class="line"><span class="cl">as it will help us to decide which direction to take the design. At the same time,
</span></span><span class="line"><span class="cl">we are also working with the Gradle team to improve Gradle support in our IDEs
</span></span><span class="line"><span class="cl">and Gradle itself.
</span></span></code></pre></div><blockquote>
<p>Translation: we want to put the design in front of you and let feedback decide which direction it takes. At the same time, we are working with the Gradle team to improve Gradle support in our IDEs and Gradle itself.</p>
</blockquote>
<p>Politely worded, and the meaning is clear: this is not replacing Gradle, it is a side-by-side showing.</p>
<p>Migration is interesting too. Maven projects get a convert command, kotlin tool convert-project, which handles part of it automatically. Gradle projects have no tool, and the official explanation is that Gradle scripts are arbitrary code, so no deterministic conversion rules can be written.</p>
<p>So what do you do? One line in the FAQ stopped me: this is the kind of thing an AI agent is good at, given a set of skills. I went and looked. The skills sit under the Kotlin org, called kotlin-agent-skills, 1,071 stars, ten skill files, two of them specifically about migrating from Gradle to Kotlin Toolchain.</p>
<p>The rules inside are strict: every third-party Gradle plugin has exactly three paths — drop it, replace it with a native equivalent, or rewrite it yourself. Keeping it is not an option. And the convention plugins you wrote in buildSrc do not count as plugins, they count as configuration, and they have to become module templates.</p>
<p>That one is worth writing down. Treat the build script as code and an AI can do the mechanical translation. Treat it as configuration and the implicit conventions become the most expensive part of the migration.</p>
<h2 id="wrapping-up">Wrapping up</h2>
<p>Summing up this week&rsquo;s move: the name Amper retires, what it leaves behind becomes the official Kotlin toolchain, and the front door is one kotlin command plus one YAML file.</p>
<p>New projects are worth trying — three lines of config compiling four platforms is genuinely pleasant. Leave old projects alone for now. It is still Alpha, and the team itself says not all migration scenarios are covered.</p>
<p>Repo and docs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">github.com/JetBrains/kotlin-toolchain
</span></span><span class="line"><span class="cl">kotlin-toolchain.org
</span></span></code></pre></div><p>One question to close with: if you had to move your current KMP project off Gradle onto this today, which step do you think would stop you first?</p>
]]></content:encoded></item><item><title>JetBrains Pulled Swift Support Out of the KMP Plugin: Not One Link in the Toolchain Moved</title><link>http://mings.in/en/posts/kmp-ios/</link><pubDate>Tue, 06 Oct 2026 10:44:56 +0800</pubDate><guid>http://mings.in/en/posts/kmp-ios/</guid><description>On October 5 JetBrains published a short announcement with a blunt title: IDE support for the Swift language is ending in the KMP plugin. The effective versions are specific: IntelliJ IDEA 2026.3 and Android Studio Rabbit 2 | 2026.2.2.</description><content:encoded><![CDATA[<h2 id="first-what-the-announcement-literally-says">First, what the announcement literally says</h2>
<p>On October 5 JetBrains published a short announcement. The title is blunt: IDE support for the Swift language is ending in the KMP plugin. The effective versions are specific: IntelliJ IDEA 2026.3 and Android Studio Rabbit 2 | 2026.2.2.</p>
<p>The list of removed features is specific too. Copying it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">This includes editing, syntax highlighting, and navigation within Swift
</span></span><span class="line"><span class="cl">files and between Swift and Kotlin.
</span></span></code></pre></div><blockquote>
<p>In other words: editing, syntax highlighting, and navigation inside Swift files, and between Swift and Kotlin.</p>
</blockquote>
<p>Reading those lines, a lot of people&rsquo;s first reaction is that KMP on iOS is finished. Hold on. The second paragraph of the same announcement draws the boundary.</p>
<h2 id="which-layer-got-removed">Which layer got removed</h2>
<p>The second paragraph reads:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Running iOS applications and debugging Kotlin code on iOS will remain fully
</span></span><span class="line"><span class="cl">supported. Kotlin/Native functionality, such as Swift Export and importing
</span></span><span class="line"><span class="cl">Swift packages into Kotlin projects, is not affected.
</span></span></code></pre></div><blockquote>
<p>In other words: running iOS apps and debugging Kotlin code on iOS stay fully supported. Kotlin/Native features like Swift Export and importing Swift packages into Kotlin projects are unaffected.</p>
</blockquote>
<p>So what got taken away is the layer in the IDE that reads and edits Swift. The compiler, the Native toolchain, the path into Xcode are all still there.</p>
<p>I verified that second half on this machine. I already had the prebuilt Kotlin/Native 2.4.0 and Xcode 26.2, so I used them to compile a small piece of code into an iOS framework:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kotlinc-native -target ios_arm64 -produce framework -o out/shared src/shared.kt
</span></span></code></pre></div><p>Nine point eight seconds. The output is a Mach-O 64-bit arm64 dynamic library plus a header file for Xcode. Not one step in that chain regressed. Pulling the Swift features did not touch it.</p>
<p><img loading="lazy" src="/images/kmp-ios/ios-framework.png"></p>
<h2 id="so-what-actually-carries-ios">So what actually carries iOS</h2>
<p>Take the chain apart and the order is:</p>
<ol>
<li>Kotlin/Native compiles the shared code into a framework Apple can use — that is the nine point eight seconds above.</li>
<li>Xcode links that framework into the iOS app. <code>embedAndSignAppleFrameworkForXcode</code> in Gradle is exactly this job.</li>
<li>The Swift shell calls Kotlin. Today that mostly goes through Objective-C header interop, and the team is separately pushing Swift Export, still in Alpha, aimed at letting Kotlin export directly as a Swift module and dropping the header layer.</li>
<li>Reading and editing Swift in the IDE was only convenience during development. Remove it and the three layers above do not move.</li>
</ol>
<p>Get these four straight and the weight of the announcement becomes clear.</p>
<h2 id="why-they-pulled-it">Why they pulled it</h2>
<p>The official reason is usage. Their telemetry and user research show that iOS developers on KMP projects mostly write Swift in Xcode, while the people working inside the KMP plugin increasingly use Kotlin and Compose Multiplatform.</p>
<p>There is also historical baggage: this Swift tooling was originally built for AppCode, and AppCode was discontinued long ago. Then it moved to Fleet, then into the KMP plugin. Keeping its core architecture in step with the continuous changes in Swift, Xcode and the IntelliJ platform is a real investment. They weighed it and decided not to, and they state plainly that they do not currently plan to replace it with something else.</p>
<p>That trade-off is actually on the same line as KMP&rsquo;s moves these past months: they are spending effort making the Kotlin side better, not doing Xcode&rsquo;s job for it.</p>
<h2 id="the-community-side-is-ahead-of-the-secondhand-reports">The community side is ahead of the secondhand reports</h2>
<p>On the same day there was another item on the trending list, pointing the opposite direction. The iOS version of the open-source OSM editor StreetComplete entered TestFlight — the first testable build after years of porting work.</p>
<p>That port dates from a tracking issue opened in December 2023, still open today, 51 comments accumulated. The maintainer says a full port is roughly one person-year of work, that the first half of 2024 was pushed full-time on Prototype Fund money, and that by September of that year about half was done.</p>
<p>One claim in the secondhand reports has already expired. It says the Compose Multiplatform MapLibre library does not exist yet and is the blocker for the port. I went and read StreetComplete&rsquo;s build file. It currently says:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;org.maplibre.compose:maplibre-compose:0.19.0&#34;</span><span class="p">)</span>
</span></span></code></pre></div><p>That library has shipped on Maven Central from 0.11.0 all the way to 0.19.0. That gap is closed.</p>
<p>The costs have to be stated too. The prototype fund ran out; the maintainer writes in the issue that follow-on funding does not look good — OpenStreetMap has no money, NLNet&rsquo;s program is contracting, and the crowdfunding sponsors have not increased. Known issues are still open: the map does not work on iOS 15.x, forms can only be dismissed with a gesture, and there are still plenty of bugs.</p>
<h2 id="i-counted-the-repo">I counted the repo</h2>
<p>While I was there I counted the repo by source set. More convincing than reading news:</p>
<ul>
<li>commonMain: 1,281 Kotlin files, 3.6MB</li>
<li>commonTest: 287 files, 1.3MB</li>
<li>androidMain: 34 files, 43KB</li>
<li>iosMain: 33 files, 42KB</li>
</ul>
<p>So each platform gets a shell of a bit over forty KB and the same app runs on iOS and Android. Among the 33 files on the iOS side, the biggest is IosModule.kt at six thousand bytes, doing dependency injection wiring; the rest are platform-specific pieces: networking, sync, upload, periodic cleanup.</p>
<p><img loading="lazy" src="/images/kmp-ios/source-sets.png"></p>
<p>See that ratio clearly and the blast radius of the announcement is clear too: the removed features act on that forty-two KB shell, and have nothing to do with the shared three point six MB.</p>
<h2 id="these-two-stories-are-one-story">These two stories are one story</h2>
<p>JetBrains&rsquo;s choice is &ldquo;I do the Kotlin side well, the Swift side goes to Xcode&rdquo;. StreetComplete&rsquo;s approach fits that division exactly: shared logic and UI are all written in Kotlin and Compose, and the Swift side is left as a shell.</p>
<p>So the call is simple. Ask yourself a few things:</p>
<ul>
<li>Where do you actually write Swift in this project? If Xcode was open anyway, this announcement barely touches you.</li>
<li>If you read and edit Swift inside Android Studio, you need to change your workflow from now on, or stay on an older version.</li>
<li>Do not look at IDE support and toolchain support as the same thing. The first is gone. The second is not.</li>
<li>A viable technical path does not mean a project reaches the finish line. The StreetComplete story is a reminder about money and people.</li>
</ul>
<h2 id="how-long-does-the-version-on-your-machine-still-work">How long does the version on your machine still work</h2>
<p>The threshold is Android Studio Rabbit 2 | 2026.2.2 and IntelliJ IDEA 2026.3. Mine is 2026.2, so the Swift features you have are still there, they just stop getting updates. If you need them badly, upgrade a little later. If not, let it ride — Swift&rsquo;s real home has always been Xcode.</p>
<p>In your KMP project, where do you write the Swift on the iOS side? Comments are open.</p>
<p>References:</p>
<ul>
<li><a href="https://blog.jetbrains.com/kotlin/2026/10/discontinuing-swift-language-ide-support-in-the-kotlin-multiplatform-plugin/">https://blog.jetbrains.com/kotlin/2026/10/discontinuing-swift-language-ide-support-in-the-kotlin-multiplatform-plugin/</a></li>
<li><a href="https://github.com/streetcomplete/StreetComplete/issues/5421">https://github.com/streetcomplete/StreetComplete/issues/5421</a></li>
</ul>
]]></content:encoded></item><item><title>Kotlin Multiplatform Compiling "Whenever It Feels Like It" -- Officially Acknowledged</title><link>http://mings.in/en/posts/kmp-sepcomp/</link><pubDate>Sat, 03 Oct 2026 22:24:18 +0800</pubDate><guid>http://mings.in/en/posts/kmp-sepcomp/</guid><description>If you write Kotlin Multiplatform you have probably hit this: the line is red in the IDE, underlined with a cannot find symbol, you hesitate for ages and do not dare commit, then you build and it is green, and it even runs correctly.</description><content:encoded><![CDATA[<h2 id="start-with-that-moment">Start with that moment</h2>
<p>If you write Kotlin Multiplatform you have probably hit this: the line is red in the IDE, underlined with a cannot find symbol, you hesitate for ages and do not dare commit, then you build and it is green, and it even runs correctly.</p>
<p>We used to blame the IDE acting up, an index that had not been built, just clear the caches. On September 28 JetBrains published a post, and the first line is blunt:</p>
<blockquote>
<p>The current compilation approach to Kotlin Multiplatform projects works, but sometimes can lead to unexpected or hard-to-predict behavior.</p>
</blockquote>
<p>In plain terms: the current compilation approach works, but sometimes it hands you behavior you cannot predict. Then it lists two examples, both things we have hit and never explained:</p>
<blockquote>
<p>The IDE analysis disagrees with the compiler on an overload, type inference, or whether the code should compile at all – with the IDE being stricter than the compiler.</p>
</blockquote>
<blockquote>
<p>Your <code>commonTest</code> code can unexpectedly call something from a platform source set, breaking your assumptions.</p>
</blockquote>
<p>One is the IDE being stricter than the compiler, the other is commonTest quietly reaching into a platform source set. Below I explain the mechanism first, then give my own test results, because in the official example you only get shown half the price of the fix.</p>
<h2 id="why-the-disagreement-exists">Why the disagreement exists</h2>
<p>The key is what common code resolves its dependencies against when it compiles.</p>
<p>In a KMP module, commonMain is not compiled into an independent artifact and that is the end of it. When compiling the JVM target, the compiler takes commonMain and jvmMain together and compiles them against the dependency&rsquo;s platform artifact (a jar on JVM). So a call in commonMain has a chance to implicitly land on a declaration in the dependency&rsquo;s jvmMain.</p>
<p>The IDE&rsquo;s code analysis does not do this. It assumes common code can only see the dependency&rsquo;s common declarations, the part listed in the metadata KLIB. jvmMain declarations are not in there, so the IDE shows red.</p>
<p>Two different understandings of the same line, and neither is wrong, but you are stuck in the middle. The post even gives a nastier version: inconsistent overload selection, where the IDE jumps to the commonMain overload while the runtime prints the platform one.</p>
<p>Then there is type inference divergence, where the common side infers Any and JVM compilation fails outright with Unresolved reference. The reverse happens too: with the same example and the flag on, the common phase fixes the return type of currentEvent() to Event, <code>.name</code> has a basis, and JVM compilation passes. So this feature cuts both ways. It makes code that got lucky fail, and it also fixes code that used to fail.</p>
<p>The difference between those two paths looks like this:</p>
<p><img loading="lazy" src="/images/kmp-sepcomp/lookup-paths.png"></p>
<h2 id="the-new-scheme-is-one-flag">The new scheme is one flag</h2>
<p>Kotlin 2.5.0-Beta1 introduced an optional separate compilation mode, so the compiler also uses the strict metadata KLIB resolution when compiling common source sets, lining up with the IDE.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="c1"># gradle.properties</span>
</span></span><span class="line"><span class="cl"><span class="na">kotlin.kmp.separateCompilation</span><span class="o">=</span><span class="s">true</span>
</span></span></code></pre></div><p>It is marked Experimental and off by default. One bonus: it makes incremental compilation of common source sets possible, which was not achievable before.</p>
<p>The official example reads easily. Code the IDE flagged red but the compiler accepted now fails to compile too; just wire up expect/actual explicitly and you are fine.</p>
<h2 id="test-one-it-really-does-stop-you">Test one: it really does stop you</h2>
<p>I built a minimal KMP project locally (Kotlin 2.5.0-Beta1, two targets: jvm and js), two modules:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">lib/  src/commonMain/Marker.kt      # interface only
</span></span><span class="line"><span class="cl">      src/jvmMain/Foo.kt            # class Foo, declared only in the platform source set
</span></span><span class="line"><span class="cl">app/  src/commonMain/Main.kt        # directly new a Foo from lib
</span></span></code></pre></div><p>app depends on lib, and app&rsquo;s commonMain news up a class that only exists in lib/jvmMain.</p>
<p>Flag off:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&gt; Task :app:compileKotlinJvm
</span></span><span class="line"><span class="cl">BUILD SUCCESSFUL in 30s
</span></span></code></pre></div><p>Flag on, same code:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">e: app/src/commonMain/kotlin/Main.kt:3:12 Unresolved reference &#39;Foo&#39;.
</span></span><span class="line"><span class="cl">e: app/src/commonMain/kotlin/Main.kt:6:13 Unresolved reference &#39;Foo&#39;.
</span></span><span class="line"><span class="cl">BUILD FAILED in 2s
</span></span></code></pre></div><p>The post was right, and it stops you cold. Not a warning.</p>
<p>Same for commonTest. I put a PlatformOnly class in app&rsquo;s jvmMain and had commonTest new it: flag off BUILD SUCCESSFUL, flag on gives <code>Unresolved reference 'PlatformOnly'</code>.</p>
<h2 id="test-two-the-price-of-the-fix-the-official-example-does-not-show">Test two: the price of the fix the official example does not show</h2>
<p>The official fix is explicit expect/actual. lib&rsquo;s commonMain declares expect, the platform source set provides actual. I made that change and the JVM side really did pass:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="c1">// lib/commonMain
</span></span></span><span class="line"><span class="cl"><span class="k">expect</span> <span class="k">class</span> <span class="nc">Foo</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// lib/jvmMain
</span></span></span><span class="line"><span class="cl"><span class="k">actual</span> <span class="k">class</span> <span class="nc">Foo</span> <span class="k">actual</span> <span class="k">constructor</span><span class="p">()</span>
</span></span></code></pre></div><p>With the flag on, <code>:app:compileKotlinJvm</code> is BUILD SUCCESSFUL in 1s. That half of the official story is right.</p>
<p>Then I went to compile the JS side of the same module:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">e: lib/commonMain/kotlin/Marker.kt:5:1 The &#39;expect&#39; declaration &#39;lib.Foo&#39;
</span></span><span class="line"><span class="cl">   has no &#39;actual&#39; declaration in module &#39;&lt;commonMain&gt; for JS&#39;.
</span></span><span class="line"><span class="cl">BUILD FAILED in 2s
</span></span></code></pre></div><p>expect/actual is a paired contract. Every target needs its own actual. The blog example only ever showed the JVM side. Turn it on in a multiplatform project and you owe one actual per target.</p>
<h2 id="one-thing-the-official-post-does-not-mention-the-version-number-is-not-the-gate">One thing the official post does not mention: the version number is not the gate</h2>
<p>The blog says the feature was introduced in Kotlin 2.5.0-Beta1. I took the same bad code and tried it on Kotlin 2.4.20 (current stable):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># without the property
</span></span><span class="line"><span class="cl">BUILD SUCCESSFUL in 704ms
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># with kotlin.kmp.separateCompilation=true
</span></span><span class="line"><span class="cl">e: app/src/commonMain/kotlin/Main.kt:3:12 Unresolved reference &#39;Foo&#39;.
</span></span><span class="line"><span class="cl">BUILD FAILED in 11s
</span></span></code></pre></div><p>The property still flips behavior on 2.4.20, and I checked that this project really resolves to kotlin-gradle-plugin:2.4.20.</p>
<p>So this flag is not something that came into existence in 2.5, it is just that the team only started talking about it as a feature in 2.5.0-Beta1. Flip it around: do not treat the version number as a gate, and do not treat it worked on 2.4 as a conclusion either. Neither is solid. You have to run it on your own project to know.</p>
<h2 id="a-quick-reference-table">A quick reference table</h2>
<p>Here is everything above plus the official Known issues section in one table, for when you hit one of these cases:</p>
<table>
	<thead>
			<tr>
					<th>State of the old code</th>
					<th>Result under the new scheme</th>
					<th>What to do</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>commonMain sneaks in a declaration from a dependency&rsquo;s jvmMain / nativeMain</td>
					<td>compile fails with Unresolved reference</td>
					<td>have the library expose that capability as expect/actual, or push the call down into the platform source set</td>
			</tr>
			<tr>
					<td>commonTest sneaks in platform code like jvmMain</td>
					<td>test compilation fails</td>
					<td>expose a public interface via expect/actual, or move the test to jvmTest</td>
			</tr>
			<tr>
					<td>same-named overloads in common and on the platform side</td>
					<td>the chosen overload may change (my test: from platform to common)</td>
					<td>check which overload these calls actually resolved to before</td>
			</tr>
			<tr>
					<td>common type inference affected by extra supertypes on the platform actual</td>
					<td>the inferred result gets pinned down, which may fix an old error or expose code relying on the old behavior</td>
					<td>declare type boundaries explicitly on the common side</td>
			</tr>
	</tbody>
</table>
<h2 id="should-your-project-turn-it-on">Should your project turn it on</h2>
<p>Combining the above with the official Known issues section, my checklist is this:</p>
<ul>
<li>Do the libraries you depend on properly publish metadata KLIB? The new scheme turns that from nice to have into required. A library whose author cut corners will fail the moment you turn it on, and that is not your fault.</li>
<li>How much does the project rely on common calling platform? The KMP project I have routes platform differences through interfaces and barely uses expect/actual, so before turning it on I need to sweep for these calls, otherwise a pile of Unresolved reference appears all at once.</li>
<li>Do you use cinterop? The docs call out two known issues here (KT-88178, KT-41509), so projects with native C interop should be careful.</li>
<li>Modules with only one target are unaffected, the docs say so plainly.</li>
<li>It is still Experimental and off by default. Treat it as a preview, not an upgrade.</li>
</ul>
<h2 id="a-word-on-the-ios-front">A word on the iOS front</h2>
<p>In the same week JetBrains also published a TeamCity design retrospective about turning Kotlin Multiplatform iOS packaging into a guided flow: install a plugin, push, and the IDE pops a Configure CI prompt. Follow it and you go from first push to running tests, signing, shipping to TestFlight, and finally hooking up GitHub to run on every push.</p>
<p>Along the way it generates four files (<code>teamcity.yaml</code> plus Fastfile, Appfile, Gemfile) and leaves them local and uncommitted. The first build runs on a hosted macOS agent, and only after you have seen it pass does it let you commit.</p>
<p>I did not test this part, there is no TeamCity account on this machine. That section comes entirely from the official design retrospective and the official tutorial it points to.</p>
<h2 id="last">Last</h2>
<p>Back to that moment where the IDE shows red and the compiler passes. It was never an IDE bug and never your imagination. Two sets of resolution rules simply were not aligned for a long time. What the team is doing now is pulling the compiler over to the IDE&rsquo;s side: stricter, more predictable, at the price of code that used to get lucky having to be made explicit first.</p>
<p>What I want to do next is turn it on in the KMP project I have, list every Unresolved reference that pops up, and see how much expect/actual that project actually owes.</p>
<p>Have you turned this flag on in your Kotlin Multiplatform project? How many errors did it report? Tell me in the comments.</p>
<p>References:</p>
<ul>
<li>A More Reliable Compilation Scheme for Kotlin Multiplatform Modules: <a href="https://blog.jetbrains.com/kotlin/2026/09/a-more-reliable-compilation-scheme-for-kotlin-multiplatform-modules/">https://blog.jetbrains.com/kotlin/2026/09/a-more-reliable-compilation-scheme-for-kotlin-multiplatform-modules/</a></li>
<li>Designing the Kotlin Multiplatform and TeamCity Integration: <a href="https://blog.jetbrains.com/teamcity/2026/09/designing-the-kotlin-multiplatform-and-teamcity-integration/">https://blog.jetbrains.com/teamcity/2026/09/designing-the-kotlin-multiplatform-and-teamcity-integration/</a></li>
<li>Official iOS CI/CD tutorial: <a href="https://kotlinlang.org/docs/multiplatform/ios-ci-cd-teamcity.html">https://kotlinlang.org/docs/multiplatform/ios-ci-cd-teamcity.html</a></li>
</ul>
]]></content:encoded></item><item><title>A2UI Hands-On: the Agent Draws the UI, Jetpack Compose Renders It</title><link>http://mings.in/en/posts/a2ui-compose/</link><pubDate>Wed, 30 Sep 2026 09:15:53 +0800</pubDate><guid>http://mings.in/en/posts/a2ui-compose/</guid><description>Same JSON, same app, same run.</description><content:encoded><![CDATA[<h2 id="two-screenshots-first">Two screenshots first</h2>
<p>Same JSON, same app, same run.</p>
<p>The first: the form is full. Guest Ada Lovelace, date Oct 1, 2026, time 7:30 PM, party of 2, two buttons underneath reading Cancel and Confirm booking. Native Material 3 controls all the way down.</p>
<p><img alt="Fields populated correctly" loading="lazy" src="/images/a2ui-compose/fields-filled.png"></p>
<p>The second: the same form, every field empty. Not just empty. The labels on both date-time controls are gone too, leaving two gray rounded boxes.</p>
<p><img alt="Same JSON, every field empty" loading="lazy" src="/images/a2ui-compose/fields-empty.png"></p>
<p>The only difference is when I sent the <code>updateDataModel</code> message. Send it in the same batch as the components and the fields get dropped. Send it a second later, on its own, and the values land. I will come back to this. It is the most valuable hole I fell into this time.</p>
<h2 id="what-a2ui-is">What A2UI is</h2>
<p>One sentence, their words:</p>
<blockquote>
<p>A2UI (Agent to UI) is a declarative UI protocol for agent-driven interfaces. AI agents generate rich, interactive UIs that render natively across platforms (web, mobile, desktop) without executing arbitrary code.</p>
</blockquote>
<p>The agent does not emit HTML or JavaScript. It emits declarative JSON describing what the interface looks like, and the client renders it with its own native components. The scenario they use to contrast it is the classic one:</p>
<blockquote>
<p>User: &ldquo;Book a table for 2 tomorrow at 7pm&rdquo;
Agent: &ldquo;Okay, for what day?&rdquo;
User: &ldquo;Tomorrow&rdquo;
Agent: &ldquo;What time?&rdquo;</p>
</blockquote>
<p>Three round trips to fill one form. Swap in an agent that generates the form itself, with a date picker, a time picker and a submit button, and the user is operating an interface instead of talking to one.</p>
<p>The smartest design in this protocol, I think, is the catalog. It does not let the agent invent UI freely. The client declares a contract first: these are the components, these are the properties, these are the local functions. The agent can only pick and compose inside that contract. The spec puts it plainly:</p>
<blockquote>
<p>Rather than having an agent generate arbitrary UI code or invent unregistered components, the catalog acts as a contract that defines the specific UI elements, properties, and functions available to the agent.</p>
</blockquote>
<p>Add the sends-data-not-code property and the security math gets easy: no iframe, no arbitrary code execution.</p>
<h2 id="the-official-renderer-on-the-compose-side">The official renderer on the Compose side</h2>
<p>On September 23, AndroidX shipped an A2UI renderer for Jetpack Compose, 1.0.0-alpha01. I checked each of these artifacts on Google Maven; they all exist:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui.compose:compose-runtime:1.0.0-alpha01&#34;</span><span class="p">)</span>  <span class="c1">// fine-grained recomposition on Compose Snapshot state
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui.compose:compose-ui:1.0.0-alpha01&#34;</span><span class="p">)</span>       <span class="c1">// components and catalog definitions
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui:a2ui-model:1.0.0-alpha01&#34;</span><span class="p">)</span>               <span class="c1">// protocol data layer
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.a2ui:a2ui-engine:1.0.0-alpha01&#34;</span><span class="p">)</span>              <span class="c1">// parsing, schema validation
</span></span></span><span class="line"><span class="cl"><span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;androidx.compose.material3:material3-a2ui:1.0.0-alpha01&#34;</span><span class="p">)</span>  <span class="c1">// Material 3 implementation of the Basic Catalog
</span></span></span></code></pre></div><p><code>material3-a2ui</code> is the finished product: the Material 3 implementation of the Basic Catalog, one line and it runs. What you have to supply is image, video and audio players, plus callbacks for opening URLs and formatting localized text. Leaving out Coil and Media3 is deliberate:</p>
<blockquote>
<p>The A2UI libraries intentionally don&rsquo;t bundle external media and networking dependencies, such as Coil, Glide, or Media3. Instead, you provide your own renderers.</p>
</blockquote>
<p>One more thing KMP developers will ask about immediately. I unpacked the Gradle module metadata for <code>compose-runtime</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">- releaseVariantReleaseApiPublication     type=aar
</span></span><span class="line"><span class="cl">- releaseVariantReleaseRuntimePublication type=aar
</span></span></code></pre></div><p>Only aar. No klib, no JVM variant. This release is Android-only, so Compose Multiplatform cannot use it today.</p>
<h2 id="minimal-wiring">Minimal wiring</h2>
<p>Two objects on the data side: a parser and a processor.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">private</span> <span class="k">val</span> <span class="py">parser</span> <span class="p">=</span> <span class="n">A2uiMessageParser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">private</span> <span class="k">val</span> <span class="py">catalog</span> <span class="p">=</span> <span class="n">materialA2uiBasicCatalogV1</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">image</span> <span class="p">=</span> <span class="n">MaterialA2uiBasicCatalogV1Defaults</span><span class="p">.</span><span class="n">image</span> <span class="p">{</span> <span class="n">url</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">modifier</span><span class="p">,</span> <span class="n">_</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="n">Placeholder</span><span class="p">(</span><span class="s2">&#34;image&#34;</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">modifier</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">urlOpener</span> <span class="p">=</span> <span class="p">{</span> <span class="n">url</span> <span class="o">-&gt;</span> <span class="nc">Log</span><span class="p">.</span><span class="n">i</span><span class="p">(</span><span class="n">TAG</span><span class="p">,</span> <span class="s2">&#34;open </span><span class="si">$url</span><span class="s2">&#34;</span><span class="p">)</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">messageFormatter</span> <span class="p">=</span> <span class="p">{</span> <span class="n">pattern</span><span class="p">,</span> <span class="n">locale</span><span class="p">,</span> <span class="n">args</span> <span class="o">-&gt;</span> <span class="cm">/* MessageFormat */</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">localeProvider</span> <span class="p">=</span> <span class="n">A2uiLocaleProvider</span><span class="p">.</span><span class="n">Default</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">private</span> <span class="k">val</span> <span class="py">processor</span> <span class="p">=</span> <span class="n">A2uiMessageProcessor</span><span class="p">(</span><span class="n">catalogs</span> <span class="p">=</span> <span class="n">listOf</span><span class="p">(</span><span class="n">catalog</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">surfaces</span> <span class="p">=</span> <span class="n">processor</span><span class="p">.</span><span class="n">activeSurfaces</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">init</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">viewModelScope</span><span class="p">.</span><span class="n">launch</span><span class="p">(</span><span class="nc">Dispatchers</span><span class="p">.</span><span class="n">Default</span><span class="p">)</span> <span class="p">{</span> <span class="n">processor</span><span class="p">.</span><span class="n">collectMessages</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">viewModelScope</span><span class="p">.</span><span class="n">launch</span><span class="p">(</span><span class="nc">Dispatchers</span><span class="p">.</span><span class="n">Default</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">processor</span><span class="p">.</span><span class="n">outboundEvents</span><span class="p">.</span><span class="n">collect</span> <span class="p">{</span> <span class="nc">Log</span><span class="p">.</span><span class="n">e</span><span class="p">(</span><span class="n">TAG</span><span class="p">,</span> <span class="s2">&#34;OUTBOUND: </span><span class="si">$it</span><span class="s2">&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The UI side is one loop, handing each surface to <code>A2uiSurface</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">surfaces</span><span class="p">.</span><span class="n">forEach</span> <span class="p">{</span> <span class="n">surface</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span><span class="p">(</span><span class="n">surface</span><span class="p">.</span><span class="n">id</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">A2uiSurface</span><span class="p">(</span><span class="n">surfaceModel</span> <span class="p">=</span> <span class="n">surface</span><span class="p">,</span> <span class="n">modifier</span> <span class="p">=</span> <span class="nc">Modifier</span><span class="p">.</span><span class="n">fillMaxWidth</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>What goes in is three protocol messages. <code>createSurface</code> opens the canvas, <code>updateComponents</code> describes the component tree, <code>updateDataModel</code> supplies the bound values:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;v0.9&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;createSurface&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;surfaceId&#34;</span><span class="p">:</span> <span class="s2">&#34;booking&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                     <span class="nt">&#34;catalogId&#34;</span><span class="p">:</span> <span class="s2">&#34;https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json&#34;</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">{</span> <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;v0.9&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;updateComponents&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;surfaceId&#34;</span><span class="p">:</span> <span class="s2">&#34;booking&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;components&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;root&#34;</span><span class="p">,</span> <span class="nt">&#34;component&#34;</span><span class="p">:</span> <span class="s2">&#34;Column&#34;</span><span class="p">,</span> <span class="nt">&#34;children&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;title&#34;</span><span class="p">,</span> <span class="s2">&#34;nameField&#34;</span><span class="p">,</span> <span class="s2">&#34;actions&#34;</span><span class="p">]</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;title&#34;</span><span class="p">,</span> <span class="nt">&#34;component&#34;</span><span class="p">:</span> <span class="s2">&#34;Text&#34;</span><span class="p">,</span> <span class="nt">&#34;text&#34;</span><span class="p">:</span> <span class="s2">&#34;Book a Table&#34;</span><span class="p">,</span> <span class="nt">&#34;variant&#34;</span><span class="p">:</span> <span class="s2">&#34;h1&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;nameField&#34;</span><span class="p">,</span> <span class="nt">&#34;component&#34;</span><span class="p">:</span> <span class="s2">&#34;TextField&#34;</span><span class="p">,</span> <span class="nt">&#34;label&#34;</span><span class="p">:</span> <span class="s2">&#34;Guest name&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;value&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;path&#34;</span><span class="p">:</span> <span class="s2">&#34;/booking/name&#34;</span> <span class="p">},</span> <span class="nt">&#34;variant&#34;</span><span class="p">:</span> <span class="s2">&#34;shortText&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span> <span class="p">}</span> <span class="p">}</span>
</span></span></code></pre></div><p>Note that <code>value</code> on a component is not the value itself but a <code>path</code> into the data model. Which is exactly why whether that third message lands determines whether your form is full or empty.</p>
<h2 id="once-it-runs-the-pits-come-one-after-another">Once it runs, the pits come one after another</h2>
<p>Every item below is something I actually hit and then found the cause or the workaround for. Not one of them is in those two pages of docs.</p>
<ol>
<li><code>updateDataModel</code> sent in the same batch as <code>updateComponents</code> gets silently dropped. This is the nastiest one. The surface appears normally, nothing errors, and every property bound through <code>path</code> comes up empty. Resend the same bytes one second later and the fields fill in immediately. logcat says all three messages parsed OK and the surface count is 1, but the values never land:</li>
</ol>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">parsed OK -&gt; A2uiCreateSurfaceMessage surfaceId=booking
</span></span><span class="line"><span class="cl">parsed OK -&gt; A2uiUpdateComponentsMessage surfaceId=booking
</span></span><span class="line"><span class="cl">parsed OK -&gt; A2uiUpdateDataModelMessage surfaceId=booking
</span></span><span class="line"><span class="cl">activeSurfaces.size=1 ids=[booking]
</span></span></code></pre></div><p>So what I ended up doing: send <code>createSurface</code> and <code>updateComponents</code> first, wait for the surface to appear, then push the data model. That happens to be the real order a streaming agent produces anyway. Accidental and correct at the same time.</p>
<ol start="2">
<li>
<p>catalogId is v0_9, not v0_9_1. The &ldquo;stable&rdquo; example on the docs site says <code>version: v0.9.1</code> and <code>.../v0_9_1/...</code>. The actual constants in the library are <code>protocol = v0.9</code> and <code>.../v0_9/catalogs/basic/catalog.json</code>. The parser accepts either version string, but <code>createSurface.catalogId</code> has to match the id of the catalog you registered exactly. Miss it and the surface never appears, again with no error. My fix: stop copying strings out of docs and read them from the library constants.</p>
</li>
<li>
<p><code>processInput</code> is not a method on <code>A2uiMessageProcessor</code>. It is a top-level extension function in the <code>androidx.a2ui.model.processor</code> package. You have to <code>import androidx.a2ui.model.processor.processInput</code>. Copy the line from the docs verbatim and it will not compile.</p>
</li>
<li>
<p>Failures are silent. Inside, <code>processInput</code> swallows <code>A2uiException</code>, turns it into <code>processor.processError(...)</code>, and emits it only on <code>outboundEvents</code>. If you are not collecting that flow (the docs collect it, but never say what skipping it costs), protocol errors and parse failures are completely invisible and all you get on screen is a white box. First thing when debugging this library: wire up <code>outboundEvents</code>.</p>
</li>
<li>
<p><code>material3-a2ui:1.0.0-alpha01</code> does not work with the current stable Compose BOM. Its POM demands <code>androidx.compose.*</code> 1.13.0-alpha03 and pins material3 to a strict <code>[1.5.0-alpha28]</code> range. The stable BOM resolves to 1.12.x, so it will not install. I ended up dropping the BOM and pinning versions by hand.</p>
</li>
<li>
<p>There is no documentation for compileSdk 37 minor 1. <code>compileSdk = 37</code> cannot find the already-installed android-37.1. What works is:</p>
</li>
</ol>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">compileSdk</span> <span class="p">{</span> <span class="n">version</span> <span class="p">=</span> <span class="n">release</span><span class="p">(</span><span class="mi">37</span><span class="p">)</span> <span class="p">{</span> <span class="n">minorApiLevel</span> <span class="p">=</span> <span class="mi">1</span> <span class="p">}</span> <span class="p">}</span>
</span></span></code></pre></div><ol start="7">
<li>
<p>The surface root component id has to be <code>root</code>. It is a private constant. Name it anything else and your Column is not the root.</p>
</li>
<li>
<p>Enum values are pure guesswork. Get one wrong and the schema fails, and the docs do not list them:</p>
</li>
</ol>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Text.variant           h1 h2 h3 h4 h5 caption body
</span></span><span class="line"><span class="cl">Button.variant         primary secondary borderless
</span></span><span class="line"><span class="cl">TextField.variant      shortText longText number obscured
</span></span></code></pre></div><p>My approach was to <code>javap</code> the enums out of the AAR and read them one by one.</p>
<p>One more, unrelated to A2UI but impossible to dodge: adding <code>org.jetbrains.kotlin.android</code> under AGP 9.x gets rejected outright. Kotlin support has been built into AGP since 9.0, so you keep only the compose plugin.</p>
<h2 id="my-verdict">My verdict</h2>
<p>It works, and what it renders really is native Material 3, not a wrapped web page. But at alpha the docs are running behind the code. The real catalogId, the shape of <code>processInput</code>, message ordering, how to pin versions: you have to unpack the artifacts and look. Good for a proof of concept right now. Do not rush it into production.</p>
<p>On the KMP side, wait. The aar-only fact alone settles it: Compose Multiplatform only gets the Android half.</p>
<p>What I want to try next is closing the outbound path: the user taps Confirm booking, the event goes back to the agent, and the agent sends another <code>updateComponents</code> to change the UI based on the result. I only wired that path up. I never actually pressed the button, so I am not claiming here that it works.</p>
<p>Would you let an agent generate the interface in your AI chat client directly? Or would you rather hard-code the UI and only let the agent fill in content? Comments are open.</p>
<p>References:</p>
<ul>
<li>A2UI protocol site: <a href="https://a2ui.org/">https://a2ui.org/</a></li>
<li>The A2UI renderer for Jetpack Compose (official docs): <a href="https://developer.android.com/develop/ui/compose/agentic">https://developer.android.com/develop/ui/compose/agentic</a></li>
<li>Custom catalogs: <a href="https://developer.android.com/develop/ui/compose/agentic/manage-catalogs">https://developer.android.com/develop/ui/compose/agentic/manage-catalogs</a></li>
<li>A2UI Compose release page: <a href="https://developer.android.com/jetpack/androidx/releases/a2ui-compose">https://developer.android.com/jetpack/androidx/releases/a2ui-compose</a></li>
</ul>
<p>Verification boundary: both screenshots, the parse logs for the three messages, the catalogId and protocol version constants, the A/B on message ordering, and the build and install all come from running on a real emulator on this machine (Android emulator API 36.1, APK 13,471,211 bytes, BUILD SUCCESSFUL). Component property names and enum values came from <code>javap</code> on the unpacked AAR. The tap-to-agent return path was not exercised. Images, video and audio use placeholder renderers, not real loading libraries.</p>
]]></content:encoded></item><item><title>Row-Level localState Cannot Carry Nested Edits: Reworking Offline Sync with KMP + SQLDelight + Supabase</title><link>http://mings.in/en/posts/kmp-offline-sync/</link><pubDate>Mon, 28 Sep 2026 10:11:44 +0800</pubDate><guid>http://mings.in/en/posts/kmp-offline-sync/</guid><description>A user edits a checklist on the subway: adds two levels, renames one of them, deletes the one they do not want. The whole thing happens offline and only uploads once they are back above ground.</description><content:encoded><![CDATA[<h2 id="one-save-that-creates-updates-and-deletes-at-the-same-time">One save that creates, updates and deletes at the same time</h2>
<p>A user edits a checklist on the subway: adds two levels, renames one of them, deletes the one they do not want. The whole thing happens offline and only uploads once they are back above ground.</p>
<p>You want that to land on the server as one transaction. But if the client tracks sync state per row, that single save gets split into several independent requests, and if any one of them fails you are left with half-written data in the account.</p>
<p>premi jit chowdhury, the author of Nitivakya, wrote this rework up recently. The app uses KMP + SQLDelight + Supabase and started out as a daily ancient quote, living happily on a localState field (DIRTY / SYNCED / DELETED) on a single favoriteEntity. Then he added Actions, and the structure became a nested tree: Action to Action Levels to Action Level Items.</p>
<blockquote>
<p>A row-level flag tracks final row state, not multi-entity mutation intent.</p>
</blockquote>
<p>His words: a row-level flag records the final state of that row, not the intent of a multi-entity change. That sentence is where the rework starts.</p>
<h2 id="three-specific-collapses">Three specific collapses</h2>
<p>He lists three failures in the post. I will quote them first, then unpack.</p>
<blockquote>
<p>2 separate table REST calls caused foreign-key 500 errors over shaky connections.
Full-snapshot uploads risked overwriting server-calculated streak progress.
Row-level flags couldn&rsquo;t express a simultaneous &ldquo;Create Level A + Update Level B + Delete Level C&rdquo; edit.</p>
</blockquote>
<p>Child rows arrive before parent rows, the server returns a foreign key constraint error, and the child rows stay stuck in DIRTY forever. Route around the split requests with a whole-tree snapshot upload and you overwrite the streak count the server computes itself. The third is the hardest to dodge: creates, updates and deletes mixed into one save gesture cannot be expressed by a row flag at all.</p>
<p>The judgment I agree with is this — the problem is not that the flag holds the wrong value, it is that row granularity cannot carry what actually happened in this edit.</p>
<h2 id="his-new-architecture-local-state-for-the-ui-an-outbox-for-uploads">His new architecture: local state for the UI, an outbox for uploads</h2>
<p>The result separates two layers. Local entity tables keep localState, now used only by Compose to render a status dot. Sync moves to a single aggregate outbox table holding sparse JSON DTOs with only the fields that actually changed.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="n">AUTOINCREMENT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">userId</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">type</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">operation</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">entityId</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">status</span><span class="w"> </span><span class="nb">TEXT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="s1">&#39;PENDING&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">createdAt</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">payload</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">UNIQUE</span><span class="p">(</span><span class="n">userId</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">entityId</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>That unique constraint is the point: multiple edits to the same entity collapse into one row. On the Kotlin side, withSync does three things inside one database transaction: performs the local write, deep-merges the new payload into the existing payload in the queue, and removes the queue row outright when it sees a create followed by a delete.</p>
<p>Enqueueing uses ON CONFLICT with a state machine guard so an offline create is not mislabeled as an update:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="w"> </span><span class="p">(</span><span class="n">userId</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="k">operation</span><span class="p">,</span><span class="w"> </span><span class="n">entityId</span><span class="p">,</span><span class="w"> </span><span class="n">createdAt</span><span class="p">,</span><span class="w"> </span><span class="n">payload</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">,</span><span class="w"> </span><span class="o">?</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ON</span><span class="w"> </span><span class="n">CONFLICT</span><span class="p">(</span><span class="n">userId</span><span class="p">,</span><span class="w"> </span><span class="k">type</span><span class="p">,</span><span class="w"> </span><span class="n">entityId</span><span class="p">)</span><span class="w"> </span><span class="k">DO</span><span class="w"> </span><span class="k">UPDATE</span><span class="w"> </span><span class="k">SET</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">operation</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">CASE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="k">WHEN</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="p">.</span><span class="k">operation</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;INSERT&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">excluded</span><span class="p">.</span><span class="k">operation</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;UPDATE&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;INSERT&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="k">ELSE</span><span class="w"> </span><span class="n">excluded</span><span class="p">.</span><span class="k">operation</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">END</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;PENDING&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">createdAt</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">syncQueueEntity</span><span class="p">.</span><span class="n">createdAt</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">payload</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">excluded</span><span class="p">.</span><span class="n">payload</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>Uploads no longer call table by table. One Supabase RPC goes into PostgreSQL. The function locks the row with FOR UPDATE, then runs deletes and upserts in order, and the function body is itself the transaction.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">FUNCTION</span><span class="w"> </span><span class="k">public</span><span class="p">.</span><span class="n">update_action</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_action_id</span><span class="w"> </span><span class="n">uuid</span><span class="p">,</span><span class="w"> </span><span class="n">p_updated_at</span><span class="w"> </span><span class="n">timestamptz</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_action_updates</span><span class="w"> </span><span class="n">jsonb</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_levels_upsert</span><span class="w"> </span><span class="n">jsonb</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_levels_delete</span><span class="w"> </span><span class="n">uuid</span><span class="p">[]</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_items_upsert</span><span class="w"> </span><span class="n">jsonb</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">p_items_delete</span><span class="w"> </span><span class="n">uuid</span><span class="p">[]</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">NULL</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">RETURNS</span><span class="w"> </span><span class="n">void</span><span class="w"> </span><span class="k">LANGUAGE</span><span class="w"> </span><span class="n">plpgsql</span><span class="w"> </span><span class="k">SECURITY</span><span class="w"> </span><span class="k">INVOKER</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">$$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">BEGIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">PERFORM</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">public</span><span class="p">.</span><span class="n">actions</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">p_action_id</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">user_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">auth</span><span class="p">.</span><span class="n">uid</span><span class="p">()</span><span class="w"> </span><span class="k">FOR</span><span class="w"> </span><span class="k">UPDATE</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">IF</span><span class="w"> </span><span class="n">p_levels_delete</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">THEN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="k">public</span><span class="p">.</span><span class="n">action_levels</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">ANY</span><span class="p">(</span><span class="n">p_levels_delete</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">END</span><span class="w"> </span><span class="k">IF</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">-- then the atomic upsert of levels and JSONB items
</span></span></span><span class="line"><span class="cl"><span class="k">END</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="err">$$</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><h2 id="i-ran-it-locally">I ran it locally</h2>
<p>I ran the article&rsquo;s SQL on sqlite3 3.54.0 (scripts /tmp/kmp-article/outbox.sql and atomicity.sql). All three conclusions hold.</p>
<p>INSERT while offline, then UPDATE the same entity: operation really does stay INSERT, createdAt is not refreshed, and only payload is replaced.</p>
<p>But INSERT followed by a straight DELETE is beyond what the SQL layer handles: the row stays, operation flips to DELETE, and payload is wiped to NULL. The author says that self-cancellation happens in the Kotlin transaction, and the test backs that division of labor up.</p>
<p>The foreign key case reproduced too. Insert into the child table first and you get FOREIGN KEY constraint failed; wrapped in a transaction it rolls back cleanly and neither the parent nor the child table keeps half a row. That is the core reason he insists on RPC rather than per-table REST.</p>
<p>On the Kotlin side I compiled and ran deepMerge and the cancellation logic with the kotlinc from the Gradle cache (kotlin-compiler-embeddable 2.4.10). Four offline edits — create level-3, rename level-3, create level-4, delete level-4 — collapse into one row with one payload:</p>
<pre tabindex="0"><code>queue row entityId=action-101 operation=INSERT
merged payload={&#34;levelsUpsert&#34;:{&#34;level-3&#34;:{&#34;title&#34;:&#34;Deep Meditation&#34;,&#34;sequence&#34;:3},
&#34;level-4&#34;:{&#34;title&#34;:&#34;Breath Work&#34;,&#34;sequence&#34;:4}},&#34;levelsDelete&#34;:{&#34;level-4&#34;:{&#34;id&#34;:&#34;level-4&#34;}}}
enqueue count=4, final queue rows=1
</code></pre><p>Create action-202 and then delete it instead, and nothing is left in the queue. Zero network calls.</p>
<h2 id="one-edge-i-ran-into">One edge I ran into</h2>
<p>In that payload above, level-4 appears in both levelsUpsert and levelsDelete. It is created and deleted at the same time.</p>
<p>His deepMerge only merges at key level and does not cancel the same entity across those two maps. So automatic create-then-delete cancellation only covers the INSERT plus DELETE case, the whole-row cancel. A create-then-delete inside the same level has to be handled in order by the server RPC, or pruned by the client during merge.</p>
<p>In other words, the sparse payload buys you out of whole-tree uploads, but it splits conflict resolution across two places: the client transaction and the execution order of the server function. Get either one wrong and you end up with something deleted locally that still exists on the server.</p>
<h2 id="four-questions-to-check-your-own-setup">Four questions to check your own setup</h2>
<p>First, does your row-level flag record final state or change intent? If it is the former, a nested structure exposes it immediately.</p>
<p>Second, what happens when a child entity reaches the server before its parent? If the answer is an error and a row stuck in DIRTY, your sync path is missing an atomic entry point.</p>
<p>Third, does a whole-tree snapshot overwrite fields the server computes? Streaks, points and counters are the ones that get silently rewritten.</p>
<p>Fourth, insert-then-delete self-cancellation: do you do it on the client or leave it to the server? Doing it in both places is not bad. Doing it in neither is a data hole.</p>
<h2 id="finally">Finally</h2>
<p>What is worth reading here is not the outbox pattern itself but how clearly the author draws the line on when to change architecture: for simple one-to-one flags, a row-level flag is enough; once nested composite edits appear, store the change intent as a first-class thing.</p>
<p>How do you handle composite offline edits? Tell me your approach in the comments.</p>
<p>References:</p>
<ul>
<li>Original post: <a href="https://premjitchowdhury.medium.com/when-offline-sync-relying-only-on-local-state-breaks-and-how-i-fixed-it-in-nitivakya-kmp-245e05892ac3">https://premjitchowdhury.medium.com/when-offline-sync-relying-only-on-local-state-breaks-and-how-i-fixed-it-in-nitivakya-kmp-245e05892ac3</a></li>
<li>Reddit thread: <a href="https://www.reddit.com/r/Kotlin/comments/1wrdk0q/when_offline_sync_relying_only_on_local_state/">https://www.reddit.com/r/Kotlin/comments/1wrdk0q/when_offline_sync_relying_only_on_local_state/</a></li>
<li>Supabase database functions and RPC: <a href="https://supabase.com/docs/guides/database/functions">https://supabase.com/docs/guides/database/functions</a></li>
</ul>
<p>Verification boundary: the three SQLite conclusions and the Kotlin merge logic in this article were actually run locally (sqlite3 3.54.0, kotlinc 2.4.10). The Supabase RPC function and PostgREST&rsquo;s real behavior were not verified against a project; the function body is excerpted from the original post.</p>
]]></content:encoded></item><item><title>Building My Own PikPak Client with KMP: Not a Single expect/actual</title><link>http://mings.in/en/posts/kmp-piko/</link><pubDate>Sun, 27 Sep 2026 08:30:33 +0800</pubDate><guid>http://mings.in/en/posts/kmp-piko/</guid><description>A couple of days ago a post about a niche piece of software crossed my feed — a project called Piko, posted by its own developer: an open-source third-party PikPak client, Android and Windows, sharing both UI and business code across the two.</description><content:encoded><![CDATA[<p>A couple of days ago a post about a niche piece of software crossed my feed — a project called Piko, posted by its own developer: an open-source third-party PikPak client, Android and Windows, sharing both UI and business code across the two.</p>
<p>Cloud drive clients are nothing new, but this year I have heard one codebase, two platforms so many times I&rsquo;ve gone numb to it. So what actually made me stop was not that it runs on two platforms. It&rsquo;s the shape it gave that idea.</p>
<p>I cloned the repo and counted, and then I got stuck on one thing.</p>
<h2 id="the-structure-first">The structure first</h2>
<p>Piko is Kotlin Multiplatform plus Compose Multiplatform, five modules. The measured code volume looks like this:</p>
<ul>
<li><code>shared</code> state and business logic, 79 files under <code>commonMain</code></li>
<li><code>ui</code> shared interface, 74 files under <code>commonMain</code>, <strong>this is the only source set it has</strong></li>
<li><code>app</code> Android entry point, 22 files</li>
<li><code>desktopApp</code> desktop entry point, 29 files</li>
<li><code>cli</code> a command-line tool for development, not shipped</li>
</ul>
<p><img alt="Piko module and source set layout" loading="lazy" src="/images/kmp-piko/01-modules.png"></p>
<p>The first two add up to 153 files and over 31,000 lines of Kotlin, and that&rsquo;s the part both platforms share. The last two are the entry points, 51 files and 8,000-odd lines together.</p>
<p>So far this looks normal. What&rsquo;s not normal is the next command I ran.</p>
<h2 id="not-a-single-line-of-expectactual">Not a single line of expect/actual</h2>
<p>In KMP, the textbook answer for platform differences is expect/actual: write <code>expect fun</code> in common code, write <code>actual fun</code> in each platform&rsquo;s source set. I meant to count how many it used so I could quote it as an example.</p>
<pre tabindex="0"><code>grep -rn &#34;^\s*expect &#34; --include=&#34;*.kt&#34; shared ui app desktopApp
expect declarations: 0
actual implementations: 0
</code></pre><p>I assumed my regex was wrong and tried a few variations. Same result. A project with 31,000 lines of shared code and 46,000 lines of Kotlin repo-wide uses expect/actual zero times.</p>
<p>What&rsquo;s stranger is the source set layout. <code>shared</code> has no <code>androidMain</code>, <code>ui</code> doesn&rsquo;t even have <code>desktopMain</code>, and the one platform-specific source set in <code>shared</code> is called <code>jvmSharedMain</code> — one file, 67 lines.</p>
<p>So this isn&rsquo;t &ldquo;used rarely&rdquo;. It&rsquo;s a completely different approach.</p>
<h2 id="its-answer-interfaces-plus-runtime-injection">Its answer: interfaces, plus runtime injection</h2>
<p>The author wrote one line in his architecture notes that I think is worth more than any paragraph in a tutorial:</p>
<blockquote>
<p>Platform differences go through interfaces, not expect/actual</p>
</blockquote>
<p>The reason is in a comment sitting on the capability interface definition:</p>
<blockquote>
<p>The implementations need a Context, a window, and each platform&rsquo;s own playback backend. These are runtime objects, not compile-time platform differences.</p>
</blockquote>
<p>That line cut through a muddle I&rsquo;d been carrying. expect/actual solves compile-time differences: same function name, one implementation per platform, the compiler picks. But the things a real client needs to split are almost all runtime objects. Android needs a Context, needs to register ActivityResult launchers, needs libmpv&rsquo;s SurfaceView for playback. Desktop needs a window handle, needs MPV&rsquo;s native DLL, needs the video surface zero-copied into Skia. What these share is that at compile time they look like the same kind of thing. Only the construction differs.</p>
<p>And differing construction is exactly what dependency injection is best at.</p>
<p>Here&rsquo;s how it does it. A single interface in the shared <code>ui</code> module called <code>PikoPlatform</code> lists everything the interface needs from the platform: clipboard, system color picking, download directory selection, opening and sharing local files, full-screen dialogs, in-app updates.</p>
<p>Two entry points each implement it, <code>AndroidPikoPlatform</code> and <code>DesktopPikoPlatform</code>, and the entry point wires them into the root composable <code>PikoApp(services, platform, appearance, videoPlayer)</code>. Inside the UI they&rsquo;re reached through <code>LocalPikoPlatform</code>, with no hunting for singletons.</p>
<h2 id="capabilities-a-platform-lacks-expressed-with-null">Capabilities a platform lacks, expressed with null</h2>
<p>The usual complaint about interfaces is verbosity. Take &ldquo;share a file&rdquo;: Android has a system share sheet, desktop has no equivalent, so how do you write the interface.</p>
<p>Here&rsquo;s how it handles that. Capability fields can be nullable:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="cm">/** null means this platform has no in-app updates. */</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">updater</span><span class="p">:</span> <span class="n">AppUpdateService</span><span class="p">?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="cm">/** null means this platform offers no frame preview for downloads; the entry point is hidden. */</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">videoPreview</span><span class="p">:</span> <span class="n">VideoPreviewSupport</span><span class="p">?</span>
</span></span></code></pre></div><p>Some are booleans:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="cm">/** System share sheet. false on desktop where there is no equivalent, so the UI hides &#34;Share&#34;. */</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">canShare</span><span class="p">:</span> <span class="n">Boolean</span>
</span></span></code></pre></div><p>Some are finer grained: whether the soft keyboard pops up, always false on desktop; and the shortcut modifier key — Ctrl on Windows, ⌘ on macOS — exposed as a <code>shortcutModifier</code> property.</p>
<p>The benefit of this convention is that the UI asks once whether you can do a thing, then decides for itself whether to hide the entry point. No scattering platform checks through common code for a feature that only exists on one platform. Once that kind of check piles up, common code is common in name only.</p>
<h2 id="a-third-tool-an-intermediate-source-set">A third tool: an intermediate source set</h2>
<p>Interfaces handle &ldquo;the two platforms really are different&rdquo;. But there&rsquo;s another category: code that is identical on both sides and only needs JDK APIs. The sample in Piko is the network proxy selector, <code>PikoProxySelector</code>, 67 lines.</p>
<p>It has to install a process-level <code>ProxySelector</code> into the JVM, because the SDK builds its own OkHttpClient internally, gives you no constructor parameters, and the only way in is global. That code is character-for-character identical on Android and desktop.</p>
<p>Follow the textbook and this is two <code>actual</code>s with copy-pasted content. What it does instead is add an intermediate source set, declaring in the build script that this group holds both JVM targets:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">applyDefaultHierarchyTemplate</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">common</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">group</span><span class="p">(</span><span class="s2">&#34;jvmShared&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">withCompilations</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">it</span><span class="p">.</span><span class="n">platformType</span> <span class="o">==</span> <span class="nc">KotlinPlatformType</span><span class="p">.</span><span class="n">jvm</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">                    <span class="k">it</span><span class="p">.</span><span class="n">platformType</span> <span class="o">==</span> <span class="nc">KotlinPlatformType</span><span class="p">.</span><span class="n">androidJvm</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>That&rsquo;s where the <code>jvmSharedMain</code> directory comes from. Write it once, both JVM-family targets compile it. Before writing this post I had never seen this trick in any tutorial, and of the three tools it saves the most work.</p>
<p>So by this point KMP has three tiers for drawing platform boundaries:</p>
<ol>
<li>If it can be shared, put it in <code>commonMain</code>. That&rsquo;s where the vast majority of code goes.</li>
<li>If behavior genuinely differs, use an interface plus runtime injection.</li>
<li>If the code is identical and only needs JDK APIs, add an intermediate source set.</li>
</ol>
<p>expect/actual is the fourth tier. It went unused because the first three happened to cover every need in this project. My current view is that expect/actual is for purely declarative differences — a constant, a function with no runtime state. In a genuinely complex client, fewer things land in that tier than you&rsquo;d think.</p>
<p><img alt="The tiers of tools for drawing platform boundaries" loading="lazy" src="/images/kmp-piko/02-layers.png"></p>
<h2 id="so-what-do-those-31000-lines-of-shared-code-look-like">So what do those 31,000 lines of shared code look like</h2>
<p><code>ui/src/commonMain</code> is the entire interface: theme, navigation, cloud drive, transfers, settings, trash, login, plus components. The entry is <code>PikoApp</code>, and each platform assembles its dependencies and passes them in.</p>
<p>On Android the wiring happens in <code>Application</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">val</span> <span class="py">clientManager</span> <span class="p">=</span> <span class="n">PikoClientManager</span><span class="p">(</span><span class="n">AndroidPikoSessionStore</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="n">sessionManager</span><span class="p">),</span> <span class="n">appScope</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">services</span> <span class="p">=</span> <span class="n">PikoServices</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">preferences</span> <span class="p">=</span> <span class="n">sessionManager</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">clientManager</span> <span class="p">=</span> <span class="n">clientManager</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloadManager</span> <span class="p">=</span> <span class="n">PikoDownloadCoordinator</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">storage</span> <span class="p">=</span> <span class="n">AndroidPikoDownloadStorage</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="n">sessionManager</span><span class="p">,</span> <span class="n">appScope</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">segmentDownloader</span> <span class="p">=</span> <span class="n">AndroidPikoSegmentDownloader</span><span class="p">(</span><span class="k">this</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// ...
</span></span></span><span class="line"><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">uploadSources</span> <span class="p">=</span> <span class="n">AndroidPikoUploadSources</span><span class="p">(</span><span class="k">this</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">platform</span> <span class="p">=</span> <span class="n">AndroidPikoPlatform</span><span class="p">(</span><span class="k">this</span><span class="p">)</span> <span class="p">{</span> <span class="n">appUpdater</span> <span class="p">}</span>
</span></span></code></pre></div><p>On desktop the wiring happens in <code>main</code>, same shape, different implementations: <code>DesktopPikoPlatform(settings)</code>, <code>DesktopPikoDownloadStorage</code>, <code>FilePikoSessionStore</code>. Session storage is the clearest example, because the shared interface is only 21 lines:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">interface</span> <span class="nc">PikoSessionStore</span> <span class="p">:</span> <span class="n">SessionStore</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">loadLastAccount</span><span class="p">():</span> <span class="n">String</span><span class="p">?</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">saveLastAccount</span><span class="p">(</span><span class="n">account</span><span class="p">:</span> <span class="n">String</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">loadCredentials</span><span class="p">(</span><span class="n">account</span><span class="p">:</span> <span class="n">String</span><span class="p">):</span> <span class="n">PikoCredentials</span><span class="p">?</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The Android side goes through DataStore. The desktop side writes files under <code>~/.piko</code>. The UI and business code know nothing about it.</p>
<p>One contrast here is worth flagging. The interface says what both sides must do, but it can&rsquo;t govern how far apart the two implementations drift. I read the desktop implementation, and it writes session, account and password into three separate files under <code>~/.piko</code>, with the password in plaintext. On your own machine that&rsquo;s fine. On a shared machine, worth thinking about. Same job, same interface, and the rigor of the two sides can differ by a lot.</p>
<h2 id="pitfalls">Pitfalls</h2>
<p>The architecture notes record a good number of measured pitfalls. Here are the cross-platform ones.</p>
<p><strong>The two sides&rsquo; Compose versions can disagree, and the same line of code may only fail on one of them.</strong> The desktop UI library is pinned to Compose Multiplatform 1.12.0, while Android is decided by the BOM. The reason is specific: the skiko that ships with CMP 1.13 wraps the rendering backend inside <code>OnScreenRedrawer</code>, and the playback library it uses needs to grab <code>Direct3DRedrawer</code> straight out of there for D3D11 passthrough, so opening a player window throws. In this project, upgrading CMP isn&rsquo;t changing one number; it means waiting for the upstream playback library to catch up.</p>
<p><strong>On Android, Compose has to go back to stable.</strong> The version catalog says: the 1.13 alpha crashes during fast list scrolling — an interactive ListItem reads the secondary text baseline during measurement, and an aligned child looks up a parent not yet in the table. So the BOM moved to stable, and ui and foundation landed on 1.12.x.</p>
<p><strong>Changing a shared module means compiling both sides.</strong> The author put this straight into the file meant for collaborators, because changes to <code>shared</code> and <code>ui</code> hit both entry modules at once, and compiling only one side won&rsquo;t show it. And the two sides&rsquo; material3 versions were never the same to begin with, so the same line of code can absolutely fail on only one.</p>
<p><strong>Adding a preference means touching three places.</strong> Once in the common interface, once in the Android DataStore implementation, once in desktop settings storage. Same for the global navigation stack: the main screen&rsquo;s position lives in the repository layer and is persisted, so overlays like the directory picker have to keep their own path stack — change the repository layer and the main screen&rsquo;s position moves with it.</p>
<p><strong>Dependencies use api rather than implementation for a reason.</strong> <code>shared</code> uses <code>api(libs.cmp.runtime)</code> because the state types it exposes are Compose&rsquo;s <code>State</code> directly, and consumers can only compile if they can see those types. The counterexample is kotlinx-serialization: the SDK declares it as implementation, so it isn&rsquo;t transitive, and callers have to pull in their own copy.</p>
<p><strong>One licensing detail: the source is MIT, but the Android APK as a whole is GPLv3.</strong> That&rsquo;s because the bundled mpv and FFmpeg are built with GPL. Fine for your own use. If you want to redistribute, read that carefully.</p>
<h2 id="what-it-looks-like-running">What it looks like running</h2>
<p>Piko&rsquo;s README has screenshots for each platform. I&rsquo;m putting two here: Android on top, desktop in the same window below.</p>
<p>What&rsquo;s worth comparing isn&rsquo;t the content, it&rsquo;s the navigation. On a phone the nav bar sits at the bottom; in a desktop window it moves to the left side, and that middle decision looks only at window width, not at what device it is. So shrink the desktop window enough and it turns into the phone layout, and tablet split view goes through the same check. That adaptivity is in the shared code too.</p>
<p><img alt="Piko Android" loading="lazy" src="/images/kmp-piko/phone.jpg"></p>
<p><img alt="Piko Desktop" loading="lazy" src="/images/kmp-piko/desktop.jpg"></p>
<p>Functionally it does more than I expected: magnet links resolve by work, section and episode, subtitles group with their video, ad files are excluded automatically, and you can preview everything before saving and tick the files you want. Playback progress syncs with the official PikPak client, and both downloads and streaming run eight concurrent connections. It also supports lossless clip extraction, uploads, server-side extraction, whole-drive search, duplicate finding, and viewing drive usage and monthly traffic.</p>
<p>Worth mentioning is how it was built: commit history shows Claude as a co-author, and the README states the requirements for LLM-assisted code — understand the logic of what was added, and verify it on a real device. The author says in his post that his color sense is weak, and this interface is M3 output with manual tuning.</p>
<h2 id="last">Last</h2>
<p>For a small project that needs two platforms, my advice is to carve out <code>shared</code> and <code>ui</code> first and leave them cleanly as <code>commonMain</code> only, with every platform implementation pushed into the two entry modules. That way, every time you feel like taking a shortcut and adding a platform check to a shared module, you&rsquo;ll pause first.</p>
<p>Those 67 lines of <code>PikoProxySelector</code> stuck with me the most. It looked like the most expect/actual-shaped thing in the project, and it ended up solved by adding a source set. When you have enough tools, you stop reaching for the dumbest one.</p>
<p>Project: <a href="https://github.com/NihilDigit/piko">https://github.com/NihilDigit/piko</a></p>
]]></content:encoded></item><item><title>Your Coroutine Crashed and the Stack Is Blank: Getting the Lost Call Chain Back with Decoroutinator</title><link>http://mings.in/en/posts/decoroutinator/</link><pubDate>Wed, 23 Sep 2026 07:37:31 +0800</pubDate><guid>http://mings.in/en/posts/decoroutinator/</guid><description>Production threw an IllegalStateException. You open the stack trace and see this:</description><content:encoded><![CDATA[<p>Production threw an <code>IllegalStateException</code>. You open the stack trace and see this:</p>
<pre tabindex="0"><code>java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:233)
	at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:152)
	... the next dozen lines are all coroutine internals
</code></pre><p>One line of your own code, then nothing but framework. You know where it blew up, but not <strong>who</strong> called it. <code>fetchUser</code> is called from several places in this project, and any of them could have passed in 42.</p>
<p>This is not bad luck. It is how coroutines normally behave. And fixing it takes one dependency.</p>
<h2 id="why-the-call-chain-disappears">Why the call chain disappears</h2>
<p>The whole point of coroutines is writing async code that looks synchronous. The compiler turns every suspend function into a state machine. When the function &ldquo;returns,&rdquo; it is not really returning on the JVM call stack; it hands the result to a continuation and lets the dispatcher decide where and when to continue.</p>
<p>The problem is in that &ldquo;continue.&rdquo; At the moment of suspension, the frames in between have already been popped off the JVM stack. Resumption starts on a brand new stack: it begins at the dispatcher and jumps straight into <code>fetchUser</code>&rsquo;s continuation. <code>loadProfile</code> and <code>renderScreen</code> leave no frames on that stack at any point, so the exception has no way to find them.</p>
<p>So losing frames is not a bug. It is the inevitable result of the mechanism. The Kotlin team knows this and has offered a fix: turning on <code>kotlinx.coroutines.debug</code> enables stacktrace recovery.</p>
<p>It just does not save you here. I ran this example locally and confirmed the property made it into the app JVM:</p>
<pre tabindex="0"><code>Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug=on
java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
</code></pre><p><code>loadProfile</code> and <code>renderScreen</code> are still missing. The official mechanism only covers some scenarios, and suspend-then-resume — the most common path of all — happens to be outside them.</p>
<h2 id="decoroutinator-takes-a-different-angle">Decoroutinator takes a different angle</h2>
<p><a href="https://github.com/reformator14/stacktrace-decoroutinator">Stacktrace-decoroutinator</a> goes at it by <strong>replacing the coroutine resumption implementation wholesale</strong>. The author puts it plainly:</p>
<blockquote>
<p>Decoroutinator replaces the coroutine resumption implementation. It generates methods at runtime with names that match the entire coroutine call stack.</p>
</blockquote>
<blockquote>
<p>These methods do nothing except call each other sequentially in coroutine call stack order, creating real JVM stack frames.</p>
</blockquote>
<p>In plain terms: it does not try to &ldquo;guess&rdquo; the call chain out of the continuation. It <strong>actually builds those frames</strong>. If exceptions need the JVM stack to find the caller, then give the stack something to find.</p>
<p>The project is Apache-2.0, 374 stars, has taken Kotlin Foundation grants, shipped 2.6.6 on September 10, and the repo had commits in mid-September. For a small tool, that is a healthy maintenance state.</p>
<h2 id="the-result-first-installation-second">The result first, installation second</h2>
<p>I dropped the same source file into three modules, the only difference being whether Decoroutinator is on. First run, the original version:</p>
<pre tabindex="0"><code>$ ./gradlew :plain:run
java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:233)
	...
</code></pre><p>21 lines of stack, and only 4 frames belong to the <code>demo.</code> package, two of which are the same function.</p>
<p>With the decorator on:</p>
<pre tabindex="0"><code>$ ./gradlew :decoroutinated:run
java.lang.IllegalStateException: user 42 not found
	at demo.ChainKt.fetchUser(Chain.kt:11)
	at demo.ChainKt$fetchUser$1.invokeSuspend(Chain.kt)
	at kotlin.coroutines.jvm.internal.DecoroutinatorBaseContinuationAccessorImpl.invokeSuspend(base-continuation-accessor.kt:15)
	at dev.reformator.stacktracedecoroutinator.common.internal.Provider.callInvokeSuspendIfResultIsNotCoroutineSuspended(provider-impl.kt:123)
	at dev.reformator.stacktracedecoroutinator.provider.SharedSpec.$decoroutinator$resume(provider-api.kt:106)
	at demo.ChainKt.loadProfile(Chain.kt:15)
	at demo.ChainKt.renderScreen(Chain.kt:21)
	at demo.MainKt$main$1.invokeSuspend(Main.kt:8)
	...
</code></pre><p>31 lines, 7 frames in <code>demo.</code>. What matters is not that it got longer. It is that these two lines came back:</p>
<pre tabindex="0"><code>	at demo.ChainKt.loadProfile(Chain.kt:15)
	at demo.ChainKt.renderScreen(Chain.kt:21)
</code></pre><p><code>renderScreen</code> line 21 called <code>loadProfile</code>, and <code>loadProfile</code> line 15 called <code>fetchUser</code>. A complete call chain, with line numbers. Now you can see at a glance who passed in 42.</p>
<p>Not a character of the code changed. Here are the three ways to install it.</p>
<h2 id="on-the-jvm-one-plugin-line-is-enough">On the JVM, one plugin line is enough</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">plugins</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">kotlin</span><span class="p">(</span><span class="s2">&#34;jvm&#34;</span><span class="p">)</span> <span class="n">version</span> <span class="s2">&#34;2.3.21&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">application</span>
</span></span><span class="line"><span class="cl">  <span class="n">id</span><span class="p">(</span><span class="s2">&#34;dev.reformator.stacktracedecoroutinator&#34;</span><span class="p">)</span> <span class="n">version</span> <span class="s2">&#34;2.6.6&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>This is the least work. The plugin generates helper methods into your classes at build time, and you need no extra JVM flags at runtime.</p>
<p>The same library has two other ways to enable it, both going through JVM instrumentation instead of build-time generation:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Option two: JVM startup flag</span>
</span></span><span class="line"><span class="cl">-javaagent:/path/to/stacktrace-decoroutinator-jvm-agent-2.6.6.jar
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="c1">// Option three: add the dependency, then call this once before any coroutine starts
</span></span></span><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nc">DecoroutinatorJvmApi</span><span class="p">.</span><span class="n">install</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// run your code as usual after this
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The coordinates are <code>dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm:2.6.6</code>.</p>
<p>Option three has a side effect worth mentioning up front: it works by <strong>dynamically loading</strong> a Java agent, and the JVM prints a warning at you —</p>
<pre tabindex="0"><code>WARNING: A Java agent has been loaded dynamically
WARNING: Dynamic loading of agents will be disallowed by default in a future release
</code></pre><p>Today it is only a warning. Future releases will disallow it by default. If your environment cares about that, options one and two are safer.</p>
<h2 id="in-a-kmp-project-the-plugin-does-nothing">In a KMP project, the plugin does nothing</h2>
<p>This is the pitfall I hit hardest this round, and the main reason I wanted to write this for KMP readers.</p>
<p>Seeing the plugin, the first instinct is to apply it to the multiplatform module. I did that, and the stack <strong>did not recover at all</strong>. Same as no install. Worse, it threw a different error along the way:</p>
<pre tabindex="0"><code>Exception in thread &#34;main&#34; java.lang.NoClassDefFoundError:
dev/reformator/stacktracedecoroutinator/provider/BaseContinuationExtractor
	at demo.MainKt.main(Main.kt:6)
</code></pre><p>Which means the plugin did rewrite bytecode, but left the classes its own runtime needs outside the classpath. I manually added the three artifacts the plugin injects into a normal <code>kotlin(&quot;jvm&quot;)</code> module, and the <code>NoClassDefFoundError</code> went away — <strong>but the stack still did not recover.</strong></p>
<p>The reason is not hard to work out: the plugin only wires itself into a plain JVM module&rsquo;s compile task, and a KMP jvm target&rsquo;s compile task is outside what it takes over. So the bytecode moved a little, and the part that actually generates the &ldquo;fake call chain&rdquo; never ran.</p>
<p>For a KMP project the right move is to skip build-time generation and use runtime instrumentation instead. Both of these are tested and working:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">jvmToolchain</span><span class="p">(</span><span class="mi">21</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">jvm</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="n">sourceSets</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">jvmMain</span><span class="p">.</span><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="c1">// Option three needs this; drop the line if you are using the agent
</span></span></span><span class="line"><span class="cl">      <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm:2.6.6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Option two: the agent goes only into the run args, no source or dependency changes
</span></span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">decoroutinatorAgent</span> <span class="k">by</span> <span class="n">configurations</span><span class="p">.</span><span class="n">creating</span>
</span></span><span class="line"><span class="cl"><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">decoroutinatorAgent</span><span class="p">(</span><span class="s2">&#34;dev.reformator.stacktracedecoroutinator:stacktrace-decoroutinator-jvm-agent:2.6.6&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="n">tasks</span><span class="p">.</span><span class="n">register</span><span class="p">&lt;</span><span class="n">JavaExec</span><span class="p">&gt;(</span><span class="s2">&#34;runKmp&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">mainClass</span><span class="p">.</span><span class="k">set</span><span class="p">(</span><span class="s2">&#34;demo.MainKt&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">classpath</span> <span class="p">=</span> <span class="n">files</span><span class="p">(</span><span class="n">kotlin</span><span class="p">.</span><span class="n">targets</span><span class="p">.</span><span class="n">getByName</span><span class="p">(</span><span class="s2">&#34;jvm&#34;</span><span class="p">).</span><span class="n">compilations</span><span class="p">.</span><span class="n">getByName</span><span class="p">(</span><span class="s2">&#34;main&#34;</span><span class="p">).</span><span class="n">output</span><span class="p">.</span><span class="n">allOutputs</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="n">configurations</span><span class="p">.</span><span class="n">getByName</span><span class="p">(</span><span class="s2">&#34;jvmRuntimeClasspath&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="n">jvmArgs</span><span class="p">(</span><span class="s2">&#34;-javaagent:</span><span class="si">${decoroutinatorAgent.singleFile.absolutePath}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Both produced recovered stacks inside the KMP module: <code>loadProfile</code> and <code>renderScreen</code> present, 7 frames in <code>demo.</code>.</p>
<p>One more thing has to be said clearly: <strong>Native and JS targets are out, and it is not a configuration problem</strong>. The library works by building stack frames on the JVM. No JVM, no mechanism. The author was asked in issue #5 whether a multiplatform version was planned, and answered that there is no plan for now. That issue has been open since 2022.</p>
<p>So the real choice for a KMP project is: install it for the JVM/Android side, leave iOS alone. If you are mostly chasing iOS crashes, this article will not help you.</p>
<h2 id="known-pitfalls">Known pitfalls</h2>
<p>The README lists a few, and every one of them is real:</p>
<ul>
<li>Android only supports the plugin route, and it depends on <code>MethodHandle</code>, so it needs API 26 (Android 8) or above. On older versions it <strong>silently turns recovery off</strong> instead of crashing, so it is safe to include.</li>
<li>R8/ProGuard breaks it. Obfuscation renames the classes the runtime needs, so add <code>decoroutinatorAndroidProGuardRules()</code> to your release rules.</li>
<li>Coverage disappears when used with Jacoco. This is a Jacoco limitation; the fix is to make Jacoco&rsquo;s agent run before Decoroutinator&rsquo;s.</li>
<li>The Shadow plugin has a known conflict (<a href="https://github.com/GradleUp/shadow/issues/882">shadow#882</a>). If you build a fat jar, read the workaround first.</li>
<li>Robolectric puts its classes in a separate classloader, which makes tests fail. You need to exclude <code>dev.reformator.stacktracedecoroutinator.</code> in <code>packagesToNotAcquire</code>.</li>
</ul>
<p>Also, if you only want it in tests, the official advice is to apply the plugin in a separate subproject for tests. If that is hard to split out, there is a switch:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">stacktraceDecoroutinator</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">androidTestsOnly</span> <span class="p">=</span> <span class="k">true</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>To check whether it actually got installed at runtime, query the status:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">DecoroutinatorCommonApi</span><span class="p">.</span><span class="n">getStatus</span> <span class="p">{</span> <span class="k">it</span><span class="p">()</span> <span class="p">}</span>
</span></span></code></pre></div><p>On Android it can also bundle <code>DebugProbes</code> for you, via <code>embedDebugProbesForAndroid = true</code>, which lets you dump coroutine state at runtime.</p>
<h2 id="should-you-install-it">Should you install it</h2>
<p>I lean yes, especially if someone on the team is still locating coroutine bugs by adding logs and guessing.</p>
<p>What it does is narrow: put real frames on the JVM stack. No API changes, no invasive refactor, and removing it is a one-dependency change. The cost is one more instrumentation layer at runtime, plus the pitfall list above — most of which only show up in Android packaging. A pure JVM project barely notices.</p>
<p>One caution: it changes the <strong>resumption implementation</strong> itself. So run a pass in staging before shipping to confirm there is no behavior difference, rather than flipping it on in your critical path.</p>
<hr>
<p>The full comparison project is at <a href="https://github.com/trewvip-arch/decoroutinator-kmp-demo">https://github.com/trewvip-arch/decoroutinator-kmp-demo</a></p>
<p>All three modules share one source file, <code>plain</code> / <code>decoroutinated</code> / <code>kmp</code>. Run it once and you see the original of every stack output in this article:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./gradlew :plain:run          <span class="c1"># not installed, chain is broken</span>
</span></span><span class="line"><span class="cl">./gradlew :decoroutinated:run <span class="c1"># plugin route, chain intact</span>
</span></span><span class="line"><span class="cl">./gradlew :kmp:runKmp         <span class="c1"># KMP + agent, chain intact</span>
</span></span><span class="line"><span class="cl">./gradlew :kmp:runKmpInstall  <span class="c1"># KMP + install(), chain intact</span>
</span></span></code></pre></div><p>Environment: Stacktrace-decoroutinator 2.6.6, Kotlin 2.3.21, kotlinx-coroutines 1.11.0, JDK 21, macOS. Every stack output here was actually run.</p>
]]></content:encoded></item><item><title>ADK for Kotlin 1.1 + Ktor 3.6: An AI Agent Primer for KMP Developers</title><link>http://mings.in/en/posts/adk-kotlin-quickstart/</link><pubDate>Tue, 22 Sep 2026 07:55:03 +0800</pubDate><guid>http://mings.in/en/posts/adk-kotlin-quickstart/</guid><description>Python&amp;rsquo;s agent frameworks have already cycled through several generations — LangChain, LlamaIndex, and now Google&amp;rsquo;s own ADK. Kotlin has been quiet the whole time — not because nobody wanted to build one, but because there was nothing official to lean on, and nobody quite dared put it in production.</description><content:encoded><![CDATA[<p>Python&rsquo;s agent frameworks have already cycled through several rounds — LangChain, LlamaIndex, and then Google&rsquo;s own ADK. Kotlin has been dead quiet the whole time — not that nobody wanted to build one, but there was nothing official to lean on, and nobody quite dared put it in production.</p>
<p>Google&rsquo;s <code>adk-kotlin</code> hit 1.0 GA on September 7. 1.1.0 came on September 15, and the Ktor 3.6.0 artifacts landed on Maven Central on September 16. I took those three and built something that actually works: <strong>an agent that can go verify for itself whether a given KMP library supports iOS</strong>, with the server and the client in the same KMP project.</p>
<p>One thing up front. I stepped on a landmine while writing this: the request body I copied out of the source returned a 400 on a real server, and <strong>with no error message at all</strong>. It took me the better part of half an hour to pin down. It was a version issue. That gets its own section later, because it&rsquo;s more worth remembering than the tutorial itself.</p>
<h2 id="what-this-agent-solves">What this agent solves</h2>
<p>The question KMP developers ask most often is: does this library support iOS?</p>
<p>READMEs often don&rsquo;t answer it, or answer with something out of date. The authoritative source is the <strong>Gradle module metadata</strong> the library publishes to Maven Central — that <code>.module</code> file is generated by the build, and what it lists is the platform variants this particular compile actually produced.</p>
<p>So the tool does something very direct: pull down the <code>.module</code>, parse out every variant name, and count how many mention iOS.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@Tool</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">checkLibraryPlatforms</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Maven group id, e.g. com.google.adk&#34;</span><span class="p">)</span> <span class="n">group</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Maven artifact id, e.g. google-adk-kotlin-core&#34;</span><span class="p">)</span> <span class="n">artifact</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Version to check, e.g. 1.1.0&#34;</span><span class="p">)</span> <span class="n">version</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">):</span> <span class="n">String</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">url</span> <span class="p">=</span> <span class="s2">&#34;https://repo1.maven.org/maven2/</span><span class="si">${group.replace(&#39;.&#39;, &#39;/&#39;)}</span><span class="s2">/&#34;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="si">$artifact</span><span class="s2">/</span><span class="si">$version</span><span class="s2">/</span><span class="si">$artifact</span><span class="s2">-</span><span class="si">$version</span><span class="s2">.module&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ... fetch, parse, count iOS variants
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><code>@Tool</code> and <code>@Param</code> are ADK annotations. Why they matter comes later.</p>
<h2 id="structure-why-two-modules">Structure: why two modules</h2>
<pre tabindex="0"><code>adk-kotlin-agent-quickstart/
├── shared/          KMP module
│   └── src/
│       ├── commonMain/   Ktor client + protocol DTOs (shared by jvm / iOS)
│       ├── jvmMain/      CLI entry point + CIO engine
│       └── iosMain/      Darwin engine
└── agent-server/    pure JVM module: ADK agent + HTTP server
</code></pre><p>It looks like it should fit in one module. It doesn&rsquo;t. <code>google-adk-kotlin-webserver</code> depends internally on <strong>Ktor 2.3.13</strong>, while the client needs <strong>Ktor 3.6.0</strong>. Put them together and Gradle&rsquo;s &ldquo;highest version wins&rdquo; drags the server up to Ktor 3 too, but ADK&rsquo;s code was compiled against the Ktor 2 API and blows up at runtime.</p>
<p>Not a guess. After it came up I looked at the classpath, and the server really was this set:</p>
<pre tabindex="0"><code>ktor-server-netty-jvm-2.3.13.jar
ktor-server-core-jvm-2.3.13.jar
ktor-client-core-jvm-2.3.13.jar
...
</code></pre><p>The client side is on 3.6.0. Physically separated, everybody&rsquo;s happy.</p>
<h2 id="version-pinning">Version pinning</h2>
<p>KSP has to pair exactly with Kotlin. This is the easiest place to bury a bug. I copied the exact combination the <code>google/adk-kotlin</code> repo uses for its own build:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">versions</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">kotlin</span> <span class="p">=</span> <span class="s2">&#34;2.3.21&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">ksp</span> <span class="p">=</span> <span class="s2">&#34;2.3.9&#34;</span>          <span class="c"># bump with kotlin, don&#39;t touch it alone</span>
</span></span><span class="line"><span class="cl"><span class="nx">adk</span> <span class="p">=</span> <span class="s2">&#34;1.1.0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">ktor</span> <span class="p">=</span> <span class="s2">&#34;3.6.0&#34;</span>
</span></span></code></pre></div><p>Three server dependencies:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">implementation</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">adk</span><span class="p">.</span><span class="n">core</span><span class="p">)</span>        <span class="c1">// agents / models / tools / sessions / runners
</span></span></span><span class="line"><span class="cl">  <span class="n">ksp</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">adk</span><span class="p">.</span><span class="n">processor</span><span class="p">)</span>              <span class="c1">// generate tool schemas at compile time
</span></span></span><span class="line"><span class="cl">  <span class="n">implementation</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">adk</span><span class="p">.</span><span class="n">webserver</span><span class="p">)</span>   <span class="c1">// expose the agent over HTTP
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h2 id="how-a-tool-becomes-a-schema">How a tool becomes a schema</h2>
<p>This is the biggest difference between ADK for Kotlin and the Python version. Python leans on runtime reflection. Kotlin does it at <strong>compile time</strong> with KSP.</p>
<p>This one line inside <code>KmpSupportAgent</code> is the key:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">tools</span> <span class="p">=</span> <span class="n">KmpLibraryService</span><span class="p">().</span><span class="n">generatedTools</span><span class="p">()</span>
</span></span></code></pre></div><p>I never wrote a single line of <code>generatedTools()</code> anywhere in the project. After compilation it turns up at:</p>
<pre tabindex="0"><code>agent-server/build/generated/ksp/main/kotlin/com/example/adk/server/
├── CheckLibraryPlatformsTool.kt
├── ListAvailableVersionsTool.kt
└── KmpLibraryService_GeneratedTools.kt
</code></pre><p>The last one contains:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">public</span> <span class="k">fun</span> <span class="nf">KmpLibraryService</span><span class="p">.</span><span class="n">generatedTools</span><span class="p">():</span> <span class="n">List</span><span class="p">&lt;</span><span class="n">FunctionTool</span><span class="p">&gt;</span> <span class="p">=</span> <span class="n">listOf</span><span class="p">(</span> <span class="o">..</span><span class="p">.</span> <span class="p">)</span>
</span></span></code></pre></div><p>The upside is no reflection at runtime, which keeps R8/obfuscation, Native images and startup time clean. The cost is that changing a tool signature forces a recompile, and dynamic tool registration is off the table.</p>
<h2 id="the-agent-itself">The agent itself</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">object</span> <span class="nc">KmpSupportAgent</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@JvmField</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">rootAgent</span> <span class="p">=</span> <span class="n">LlmAgent</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;kmp_library_platform_agent&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span> <span class="p">=</span> <span class="n">Gemini</span><span class="p">(</span><span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;gemini-3.1-flash-lite&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">instruction</span> <span class="p">=</span> <span class="n">Instruction</span><span class="p">(</span><span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">      You are an assistant for Kotlin Multiplatform developers.
</span></span></span><span class="line"><span class="cl"><span class="s">      When the user asks whether a library supports a platform, never answer
</span></span></span><span class="line"><span class="cl"><span class="s">      from memory. Call `checkLibraryPlatforms` and read the result.
</span></span></span><span class="line"><span class="cl"><span class="s">      ...
</span></span></span><span class="line"><span class="cl"><span class="s">    &#34;&#34;&#34;</span><span class="p">.</span><span class="n">trimIndent</span><span class="p">()),</span>
</span></span><span class="line"><span class="cl">    <span class="n">tools</span> <span class="p">=</span> <span class="n">KmpLibraryService</span><span class="p">().</span><span class="n">generatedTools</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>A note on <code>@JvmField</code>. Every agent&rsquo;s <code>rootAgent</code> in the official examples carries it. I copied it at first too and assumed it was required. All it does is turn a Kotlin <code>object</code> property into a real static field (instead of <code>INSTANCE.getRootAgent()</code>), which makes life easier for Java callers. I removed it, recompiled, ran it again — the server still came up and <code>/list-apps</code> still returned. Because <code>AdkServerConfig.inMemory(agent)</code> takes the agent object directly and never goes through reflection. Copying it is fine, just don&rsquo;t believe it won&rsquo;t start without it.</p>
<p>One thing I deliberately spelled out in the instruction: <code>platform.type</code> is an aggregate marker. Kotlin/Native targets all report <code>native</code>, and which Apple target it actually is only shows up in the <strong>variant name</strong>. Leave that out and the model happily reads the word <code>native</code> and draws its own conclusion, when it can&rsquo;t actually get from <code>native</code> to whether iOS is in there.</p>
<h2 id="starting-the-server">Starting the server</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">config</span> <span class="p">=</span> <span class="nc">AdkServerConfig</span><span class="p">.</span><span class="n">inMemory</span><span class="p">(</span><span class="nc">KmpSupportAgent</span><span class="p">.</span><span class="n">rootAgent</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">AdkDevServer</span><span class="p">(</span><span class="n">config</span><span class="p">).</span><span class="n">start</span><span class="p">(</span><span class="n">wait</span> <span class="p">=</span> <span class="k">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><code>AdkDevServer</code> hangs a dev UI off the side. The routes look like this:</p>
<table>
	<thead>
			<tr>
					<th>Method</th>
					<th>Path</th>
					<th>Purpose</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>GET</td>
					<td><code>/list-apps</code></td>
					<td>Which agents exist, i.e. what you can put in <code>appName</code></td>
			</tr>
			<tr>
					<td>GET</td>
					<td><code>/version</code></td>
					<td>Server version</td>
			</tr>
			<tr>
					<td>POST</td>
					<td><code>/apps/{appName}/users/{userId}/sessions</code></td>
					<td>Create a session, returns the session id</td>
			</tr>
			<tr>
					<td>POST</td>
					<td><code>/run</code></td>
					<td>Run a turn, returns the full event list at once</td>
			</tr>
			<tr>
					<td>POST</td>
					<td><code>/run_sse</code></td>
					<td>Run a turn, streams back over SSE</td>
			</tr>
			<tr>
					<td>GET</td>
					<td><code>/dev-ui</code></td>
					<td>Dev UI</td>
			</tr>
	</tbody>
</table>
<p>Three of them you can verify directly:</p>
<pre tabindex="0"><code>$ curl -s localhost:8080/list-apps
[&#34;kmp_library_platform_agent&#34;]

$ curl -s localhost:8080/version
{&#34;version&#34;:&#34;1.1.0&#34;,&#34;language&#34;:&#34;kotlin&#34;,&#34;language_version&#34;:&#34;21.0.10&#34;}

$ curl -s -X POST -H &#34;Content-Type: application/json&#34; -d &#39;{}&#39; \
    localhost:8080/apps/kmp_library_platform_agent/users/demo-user/sessions
{&#34;id&#34;:&#34;b86f7324-81db-49df-8007-1700077ebdb4&#34;,&#34;appName&#34;:&#34;kmp_library_platform_agent&#34;,...}
</code></pre><p><code>/run_sse</code> uses plain SSE, one <code>data: &lt;json&gt;</code> line per event followed by a blank line. The server implements it by hand because Ktor 2.x has no SSE plugin. The comment in the source is a good read:</p>
<blockquote>
<p>Ktor 2.x has no SSE plugin, so stream manually. Use async respondBytesWriter, not respondTextWriter, whose blocking bridge parks via a coroutines internal gone in 1.11.0.</p>
</blockquote>
<blockquote>
<p>Ktor 2.x has no SSE plugin, so stream it by hand. Use the async <code>respondBytesWriter</code>, not <code>respondTextWriter</code> — its blocking bridge depends on an internal implementation that was already removed in kotlinx-coroutines 1.11.0.</p>
</blockquote>
<p>In other words, the comment is warning you about itself: bump the coroutines version and this API may break.</p>
<h2 id="client-written-in-commonmain">Client: written in commonMain</h2>
<p>This is where the actual cross-platform part lives. Request building and SSE parsing all sit in <code>commonMain</code> without touching platform APIs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">run</span><span class="p">(</span><span class="n">prompt</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span> <span class="n">sessionId</span><span class="p">:</span> <span class="n">String</span><span class="p">?</span> <span class="p">=</span> <span class="k">null</span><span class="p">):</span> <span class="n">Flow</span><span class="p">&lt;</span><span class="n">Event</span><span class="p">&gt;</span> <span class="p">=</span> <span class="n">channelFlow</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">val</span> <span class="py">sid</span> <span class="p">=</span> <span class="n">sessionId</span> <span class="o">?:</span> <span class="n">createSession</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="n">httpClient</span><span class="p">.</span><span class="n">preparePost</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">$baseUrl</span><span class="s2">/run_sse&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">contentType</span><span class="p">(</span><span class="nc">ContentType</span><span class="p">.</span><span class="nc">Application</span><span class="p">.</span><span class="n">Json</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setBody</span><span class="p">(</span><span class="n">RunRequest</span><span class="p">(</span><span class="n">appName</span><span class="p">,</span> <span class="n">userId</span><span class="p">,</span> <span class="n">sid</span><span class="p">,</span> <span class="nc">Content</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="n">prompt</span><span class="p">),</span> <span class="n">streaming</span> <span class="p">=</span> <span class="k">true</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="p">}.</span><span class="n">execute</span> <span class="p">{</span> <span class="n">response</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">channel</span> <span class="p">=</span> <span class="n">response</span><span class="p">.</span><span class="n">bodyAsChannel</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">val</span> <span class="py">line</span> <span class="p">=</span> <span class="n">channel</span><span class="p">.</span><span class="n">readLine</span><span class="p">()</span> <span class="o">?:</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(!</span><span class="n">line</span><span class="p">.</span><span class="n">startsWith</span><span class="p">(</span><span class="s2">&#34;data:&#34;</span><span class="p">))</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">      <span class="k">val</span> <span class="py">payload</span> <span class="p">=</span> <span class="n">line</span><span class="p">.</span><span class="n">removePrefix</span><span class="p">(</span><span class="s2">&#34;data:&#34;</span><span class="p">).</span><span class="n">trim</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">payload</span><span class="p">.</span><span class="n">isEmpty</span><span class="p">())</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">      <span class="k">val</span> <span class="py">event</span> <span class="p">=</span> <span class="n">json</span><span class="p">.</span><span class="n">decodeFromString</span><span class="p">&lt;</span><span class="n">Event</span><span class="p">&gt;(</span><span class="n">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">send</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="n">turnComplete</span><span class="p">)</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Only the engine is per-platform, closed off behind one <code>expect/actual</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="c1">// commonMain
</span></span></span><span class="line"><span class="cl"><span class="k">expect</span> <span class="k">fun</span> <span class="nf">createPlatformHttpClient</span><span class="p">(</span><span class="n">json</span><span class="p">:</span> <span class="n">Json</span><span class="p">):</span> <span class="n">HttpClient</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// jvmMain
</span></span></span><span class="line"><span class="cl"><span class="k">actual</span> <span class="k">fun</span> <span class="nf">createPlatformHttpClient</span><span class="p">(</span><span class="n">json</span><span class="p">:</span> <span class="n">Json</span><span class="p">)</span> <span class="p">=</span> <span class="n">HttpClient</span><span class="p">(</span><span class="n">CIO</span><span class="p">)</span> <span class="p">{</span> <span class="o">..</span><span class="p">.</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// iosMain
</span></span></span><span class="line"><span class="cl"><span class="k">actual</span> <span class="k">fun</span> <span class="nf">createPlatformHttpClient</span><span class="p">(</span><span class="n">json</span><span class="p">:</span> <span class="n">Json</span><span class="p">)</span> <span class="p">=</span> <span class="n">HttpClient</span><span class="p">(</span><span class="n">Darwin</span><span class="p">)</span> <span class="p">{</span> <span class="o">..</span><span class="p">.</span> <span class="p">}</span>
</span></span></code></pre></div><p>The loop exits on two fields: <code>partial</code> marks a streaming chunk that isn&rsquo;t finished, <code>turnComplete</code> marks the end of the turn. Without <code>turnComplete</code> you have to infer it from the connection closing, and that drags you all the way to a timeout.</p>
<h2 id="that-400">That 400</h2>
<p>Now the trap I mentioned at the top.</p>
<p>I wrote the request body from my own understanding, using the <code>app_name</code> / <code>user_id</code> / <code>new_message</code> naming — after all, that&rsquo;s how Python ADK does it, and I really did see <code>ApiModels.kt</code> in the repo source hanging an alias on every multi-word property:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@JsonNames</span><span class="p">(</span><span class="s2">&#34;app_name&#34;</span><span class="p">)</span> <span class="k">val</span> <span class="py">appName</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="nd">@JsonNames</span><span class="p">(</span><span class="s2">&#34;user_id&#34;</span><span class="p">)</span> <span class="k">val</span> <span class="py">userId</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span></code></pre></div><p>Send it:</p>
<pre tabindex="0"><code>HTTP/1.1 400 Bad Request
Content-Length: 0
</code></pre><p>No body, no stack trace, not a single ERROR line in the server log. So I tried camelCase and it came back <strong>500</strong> — which means deserialization passed and the failure happened further down (the model call). In other words:</p>
<ul>
<li>camelCase → parses, walks all the way to the Gemini SDK</li>
<li>snake_case → never reaches business logic</li>
</ul>
<p><code>@JsonNames</code> is the &ldquo;alternate name&rdquo; mechanism in kotlinx-serialization. In theory both spellings should be accepted. I even wrote a minimal repro with the same annotation and the same Json config, and snake_case parsed <strong>fine</strong>. So it isn&rsquo;t kotlinx.</p>
<p>It&rsquo;s the version. I went and read the tag:</p>
<pre tabindex="0"><code>v1.1.0&#39;s ApiModels.kt:
  data class AgentRunRequest(
    val appName: String,
    val userId: String,
    ...
  )
  → not a single @JsonNames
</code></pre><p>The commit that added those two aliases, <code>94dcdd13</code> (<code>feat: accept snake_case and camelCase in web server request bodies</code>), is dated <strong>2026-09-15</strong>, landed on <code>main</code>, and went in <strong>after the <code>v1.1.0</code> tag</strong>. There&rsquo;s also a test file in the repo that says the server reads snake_case — it describes main&rsquo;s behavior, not the 1.1.0 you can actually download.</p>
<p>Decode snake_case with the real <code>AgentRunRequest</code> and the real <code>adkJson</code> and the error is unambiguous:</p>
<pre tabindex="0"><code>kotlinx.serialization.MissingFieldException: Fields [appName, userId] are required
for type with serial name &#39;...AgentRunRequest&#39;, but they were missing
</code></pre><p>So the rule is: <strong>for 1.1.0, always camelCase.</strong> snake_case waits for the next release.</p>
<p>The lesson here isn&rsquo;t &ldquo;read the tag&rdquo;. It&rsquo;s &ldquo;reading main&rsquo;s source is not the same as reading the version you depend on&rdquo;. If I hadn&rsquo;t actually sent the request and tried it, the sample code in this post would be copied from unreleased behavior and 400 on the first run.</p>
<p>If you want a self-check trick, the <code>/version</code> endpoint is genuinely useful — the server reports its own version, and then you compare against that tag&rsquo;s source, which beats guessing against main.</p>
<h2 id="what-the-tools-actually-print">What the tools actually print</h2>
<p>This part doesn&rsquo;t need a model. The tools are pure Kotlin and run directly:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./gradlew :agent-server:runTools
</span></span></code></pre></div><p>First, cut into ADK itself:</p>
<pre tabindex="0"><code>com.google.adk:google-adk-kotlin-core:1.1.0
platform.type values: androidJvm, common, jvm
variants mentioning iOS: 0
Verdict: no iOS target. This library cannot be consumed from an
iosArm64 / iosSimulatorArm64 source set.
</code></pre><p>Now Ktor for contrast:</p>
<pre tabindex="0"><code>io.ktor:ktor-client-core:3.6.0
platform.type values: common, js, jvm, native, wasm
variants mentioning iOS: 9
  - iosArm64ApiElements-published
  - iosSimulatorArm64ApiElements-published
  - iosX64ApiElements-published
  ... (9 total, across Api / Metadata / Sources)
Verdict: iOS targets are published, so Apple targets can depend on it.
</code></pre><p>Same function, same decision logic, and the two libraries come out opposite. That contrast gets its own note below.</p>
<p>Version lookup and the error path while we&rsquo;re here:</p>
<pre tabindex="0"><code>com.google.adk:google-adk-kotlin-core — latest=1.1.0
recent: 0.5.0, 0.6.0, 0.7.0, 0.8.0, 0.9.0, 1.0.0, 1.0.1, 1.1.0

Could not fetch metadata for com.google.adk:does-not-exist:9.9.9 (HTTP 404).
The coordinates or version are probably wrong.
</code></pre><p>The error path returns a human sentence instead of throwing. What the tool hands back is a string. An exception cuts the turn off right there, and the model can&rsquo;t even say &ldquo;that library doesn&rsquo;t exist&rdquo;.</p>
<h2 id="the-cross-platform-part">The cross-platform part</h2>
<p><code>shared</code> is genuinely cross-platform. I actually compiled it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ ./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64
</span></span><span class="line"><span class="cl">BUILD SUCCESSFUL in 33s
</span></span></code></pre></div><p>Same <code>commonMain</code> code, both JVM and iOS pass.</p>
<p><code>agent-server</code> can only ever run on JVM (or Android). It&rsquo;s not a config problem — the Gradle variants <code>google-adk-kotlin-core</code> publishes to Maven Central are only <code>common</code> / <code>androidJvm</code> / <code>jvm</code>, and <code>google-genai-kotlin</code> is the same. <strong>No iOS variant, no Native variant.</strong></p>
<p>So if you want to run the agent on iOS there are two roads: put it on the server and let iOS be a client only (which is the structure of this project), or wait for upstream to add Native support. I filed an issue on the ADK repo about it, <a href="https://github.com/google/adk-kotlin/issues/504">#504</a>, no reply yet.</p>
<p>Side note: this &ldquo;agent lives on the server only&rdquo; split is actually the more sensible call in most cases — model keys never ship in the client bundle, and tool execution plus permission checks stay somewhere you control.</p>
<h2 id="how-to-run">How to run</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GOOGLE_API_KEY</span><span class="o">=</span>...
</span></span><span class="line"><span class="cl">./run-stack.sh
</span></span></code></pre></div><p>The script does four things: installs the server and starts it on 8080, polls <code>/list-apps</code> until it&rsquo;s really up, runs the client, then tears the server down and passes the client&rsquo;s exit code through untouched.</p>
<p>(The first version of that script leaked: it started the service through the launcher script Gradle generates, and that script runs java as a child process. <code>kill</code> only killed the wrapper, so the server was orphaned and sat on 8080 forever. Starting java directly is what makes <code>$!</code> the server itself.)</p>
<p>Or split it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./gradlew :agent-server:run     <span class="c1"># terminal 1</span>
</span></span><span class="line"><span class="cl">./gradlew :shared:runClient     <span class="c1"># terminal 2</span>
</span></span></code></pre></div><p>Last thing, let me be clear about how far I verified, so you don&rsquo;t hit a step down when you follow along.</p>
<p>What works: the whole project compiles (including KSP codegen); the server starts; <code>/list-apps</code>, <code>/version</code> and session creation all return 200; the client&rsquo;s <code>/run_sse</code> request is accepted by the server and reaches the model call layer in <code>com.google.genai.kotlin</code>.</p>
<p>What doesn&rsquo;t: <strong>a real model conversation</strong>. That needs your own Gemini API key, which I don&rsquo;t have. So I didn&rsquo;t paste the full conversation output for the &ldquo;does it support iOS&rdquo; question from the top of the post. You only see that with a valid key attached, and I&rsquo;m not going to paste output I never ran.</p>
<p>Code is at <a href="https://github.com/trewvip-arch/adk-kotlin-agent-quickstart">https://github.com/trewvip-arch/adk-kotlin-agent-quickstart</a></p>
<p>Apache-2.0. Open an issue in the repo if something&rsquo;s wrong.</p>
]]></content:encoded></item><item><title>Does ADK for Kotlin Support iOS? I Took Its Gradle Metadata Apart to Check</title><link>http://mings.in/en/posts/adk-kmp-ios/</link><pubDate>Tue, 22 Sep 2026 07:11:05 +0800</pubDate><guid>http://mings.in/en/posts/adk-kmp-ios/</guid><description>When I introduced ADK for Kotlin 1.0, I wrote that &amp;ldquo;if iOS support lands later, that&amp;rsquo;s one codebase reused across three platforms.&amp;rdquo; That wording was slippery — placed next to &amp;ldquo;KMP core,&amp;rdquo; it reads way too easily as &amp;ldquo;iOS should just work.&amp;rdquo;</description><content:encoded><![CDATA[<h2 id="first-a-correction">First, a correction</h2>
<p>When I introduced ADK for Kotlin 1.0, I wrote a line: &ldquo;if iOS support lands later, that&rsquo;s one codebase reused across three platforms.&rdquo; That phrasing was slippery. Put it next to &ldquo;KMP core&rdquo; and it reads far too easily as &ldquo;iOS should just work.&rdquo;</p>
<p>Then a reader asked: it&rsquo;s written in Kotlin Multiplatform, so doesn&rsquo;t iOS already work?</p>
<p>I should not have glossed over that. So this time I went and checked myself, and not by reading what the docs say. I took the build artifacts apart.</p>
<p>The conclusion, up front: <strong>no. And this is not something one extra target line fixes.</strong></p>
<h2 id="there-is-a-hard-way-to-check-what-a-kmp-library-actually-supports">There is a hard way to check what a KMP library actually supports</h2>
<p>Method first, because the method is more useful than this particular conclusion.</p>
<p>When a KMP library publishes, it generates a <strong>Gradle module metadata</strong> file (the <code>.module</code> file) listing every platform variant the library actually compiled. It is a build system artifact, not marketing copy someone wrote. A library can claim it &ldquo;supports multiple platforms,&rdquo; but if the metadata has no variant for one, the build simply cannot resolve it and compilation fails outright.</p>
<p>So to learn a library&rsquo;s real platform coverage, one command is enough:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Generic recipe: swap the package path in for group</span>
</span></span><span class="line"><span class="cl">curl -sL <span class="s2">&#34;https://repo1.maven.org/maven2/com/google/adk/google-adk-kotlin-core/1.1.0/google-adk-kotlin-core-1.1.0.module&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import sys, json
</span></span></span><span class="line"><span class="cl"><span class="s2">d = json.load(sys.stdin)
</span></span></span><span class="line"><span class="cl"><span class="s2">vs = d.get(&#39;variants&#39;, [])
</span></span></span><span class="line"><span class="cl"><span class="s2">plats = sorted({v[&#39;attributes&#39;].get(&#39;org.jetbrains.kotlin.platform.type&#39;, &#39;?&#39;) for v in vs})
</span></span></span><span class="line"><span class="cl"><span class="s2">names = [v[&#39;name&#39;] for v in vs]
</span></span></span><span class="line"><span class="cl"><span class="s2">ios = [n for n in names if &#39;ios&#39; in n.lower()]
</span></span></span><span class="line"><span class="cl"><span class="s2">print(&#39;platform.type :&#39;, &#39;, &#39;.join(plats))
</span></span></span><span class="line"><span class="cl"><span class="s2">print(&#39;含 ios 的变体 :&#39;, len(ios), &#39;个&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">for n in ios[:4]: print(&#39;   -&#39;, n)
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span>
</span></span></code></pre></div><p>Run it against ADK for Kotlin and the output is:</p>
<pre tabindex="0"><code>platform.type : androidJvm, common, jvm
含 ios 的变体 : 0 个
</code></pre><h2 id="how-to-read-that-result">How to read that result</h2>
<p>One thing is easy to misread. I nearly confused myself the first time: <strong>the iOS target does not show up in <code>platform.type</code>.</strong></p>
<p>Kotlin/Native is uniformly tagged <code>native</code> under <code>platform.type</code>. Which Apple platform it is shows up in the <strong>variant name</strong>. So the correct read is both layers together:</p>
<ul>
<li><code>platform.type</code> contains <code>native</code> → the library compiles Kotlin/Native targets</li>
<li><strong>variant names include <code>iosArm64</code> / <code>iosSimulatorArm64</code> / <code>iosX64</code></strong> → only then does iOS actually work</li>
</ul>
<p>Compare against a library that definitely supports iOS, Ktor say:</p>
<pre tabindex="0"><code>platform.type : common, js, jvm, native, wasm
含 ios 的变体 : 9 个
   - iosArm64ApiElements-published
   - iosArm64SourcesElements-published
   - iosSimulatorArm64ApiElements-published
   ...
</code></pre><p>64 variants, 9 with ios. That is what &ldquo;actually supports iOS&rdquo; looks like.</p>
<p>ADK has 8 variants. <code>platform.type</code> is only androidJvm / common / jvm, and <strong>variants with ios number zero</strong>.</p>
<p>I checked every version while I was at it, including the latest 1.1.0. No iOS anywhere.</p>
<p>The source layout agrees. Under <code>core/src/</code> the source sets are:</p>
<pre tabindex="0"><code>androidMain
commonJvmAndroidMain      ← note the name
commonMain
jvmMain
(no iosMain)
</code></pre><p>And the target declarations in <code>core/build.gradle.kts</code> are just <code>jvm()</code> plus Android. Not one line of iOS.</p>
<p>Under the <code>com.google.adk</code> group on Maven Central, <strong>the number of iOS-related artifacts is zero.</strong></p>
<h2 id="three-walls-each-harder-than-the-last">Three walls, each harder than the last</h2>
<p>Knowing there is no iOS is not enough. It is worth seeing why, because the answer decides how much &ldquo;just do it yourself&rdquo; costs.</p>
<h3 id="wall-one-they-deliberately-split-out-a-layer-called-commonjvmandroidmain">Wall one: they deliberately split out a layer called <code>commonJvmAndroidMain</code></h3>
<p>The source set name is the answer. It is the part JVM and Android share. It <code>dependsOn(commonMain)</code> and contains:</p>
<table>
	<thead>
			<tr>
					<th>Dependency</th>
					<th>Status on iOS</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>kxml2</code> (XML parsing)</td>
					<td>No Kotlin/Native version; swap libraries or write an actual</td>
			</tr>
			<tr>
					<td><code>snakeyaml</code> (YAML)</td>
					<td>Same</td>
			</tr>
			<tr>
					<td><code>opentelemetry-api</code> (Java telemetry)</td>
					<td>iOS would need OpenTelemetry&rsquo;s Swift implementation; the whole layer has to become pluggable</td>
			</tr>
			<tr>
					<td><code>reactive-streams</code></td>
					<td><strong>A JVM-only spec. Apple platforms have no counterpart at all</strong></td>
			</tr>
	</tbody>
</table>
<p><code>reactive-streams</code> is the key signal. It is the JVM world&rsquo;s reactive streams spec, and nothing like it exists on Apple platforms. ADK&rsquo;s Java interop signatures are built on top of it. The source comment puts it as &ldquo;the api-vs-implementation question for the Java-interop signatures&rdquo;.</p>
<p>The authors know perfectly well the code has two layers: one that is genuinely cross-platform common logic, and one that is &ldquo;shared by JVM and Android.&rdquo; <strong>The distinction is deliberate, because right now they only want to support those two platforms.</strong></p>
<h3 id="wall-two-jvmmain-is-all-jvm-only-too">Wall two: <code>jvmMain</code> is all JVM-only too</h3>
<p><code>gson</code>, <code>google-cloud-storage</code>, <code>mcp</code> (the Java SDK for MCP), <code>ktor</code>. Neither Google Cloud Storage nor the MCP Java SDK has an iOS version, so session persistence and cloud storage would need a separate iOS implementation.</p>
<h3 id="wall-three-the-fatal-one-google-genai-kotlin-does-not-support-ios-either">Wall three (the fatal one): <code>google-genai-kotlin</code> does not support iOS either</h3>
<p><code>commonMain</code> has this line among its dependencies:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">commonMain</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">api</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">google</span><span class="p">.</span><span class="n">genai</span><span class="p">.</span><span class="n">kotlin</span><span class="p">)</span>   <span class="c1">// com.google.genai:google-genai-kotlin:1.0.0
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>I ran the same method on it:</p>
<pre tabindex="0"><code>Platform variants: androidJvm, common, jvm
</code></pre><p><strong>Google&rsquo;s own Kotlin SDK for generative AI has no iOS target either.</strong></p>
<p>And it is an <code>api</code> dependency, not <code>implementation</code>. That means it sits on every consumer&rsquo;s compile classpath. There is no way around it.</p>
<p>So the truth is: <strong>to get ADK for Kotlin running on iOS, you first have to port Google&rsquo;s GenAI Kotlin SDK to iOS.</strong> That is a whole other SDK&rsquo;s worth of work, and it lives in Google&rsquo;s hands, outside anything you can change.</p>
<h2 id="so-what-does-compile-it-myself-with-ios-support-actually-mean">So what does &ldquo;compile it myself with iOS support&rdquo; actually mean</h2>
<p>Not a fork plus three target lines. Roughly:</p>
<table>
	<thead>
			<tr>
					<th>What it takes</th>
					<th>Size</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Add iOS targets plus actual implementations per layer (XML, YAML, telemetry, persistence, networking)</td>
					<td>Medium to large</td>
			</tr>
			<tr>
					<td>Replace <code>reactive-streams</code> with Flow, redesign the API (breaks the Java interop promise)</td>
					<td>Medium</td>
			</tr>
			<tr>
					<td>Port google-genai-kotlin to iOS, or bypass it and hit REST directly</td>
					<td><strong>Large</strong></td>
			</tr>
	</tbody>
</table>
<p>The last two are not in your repo. Especially the third: that means moving someone else&rsquo;s code.</p>
<h2 id="110s-direction-makes-this-clearer-not-murkier">1.1.0&rsquo;s direction makes this clearer, not murkier</h2>
<p>ADK for Kotlin 1.1.0 shipped September 15 (1.0 went GA September 7, 1.0.1 on September 10 — fast cadence). Look at what it adds:</p>
<ul>
<li><code>AppFunctionsToolset</code>, wiring into Android&rsquo;s AppFunctions</li>
<li>An Android AppFunctions conversion layer</li>
<li>Injects the app&rsquo;s AppFunctions descriptions into model instructions</li>
<li>A <code>BaseFutureSkillSource</code> base class for Java, plus Java examples of long-running tools</li>
<li>The <code>Plugin.onRunError</code> callback</li>
</ul>
<p>The fixes land in the same area: unknown tool names no longer kill the whole call, session recovery is fixed for workflow agents, and a turn survives when an MCP server rejects a request.</p>
<p>Look at where the weight sits. The most substantial new capability in 1.1.0 is the <strong>Android AppFunctions</strong> line — letting an agent call functions an Android app exposes.</p>
<p>Which tells you the iOS gap is not an oversight. It is a <strong>strategic choice</strong>. Google&rsquo;s energy is on the Android side: Gemini Nano, ML Kit, LiteRT-LM, Firebase AI, and now AppFunctions, all laid down for Android. ADK for Kotlin&rsquo;s &ldquo;Kotlin Multiplatform&rdquo; is currently shorthand for &ldquo;Kotlin Multiplatform (Android + JVM).&rdquo; That common layer exists for those two platforms to share, not as groundwork for iOS.</p>
<h2 id="so-what-do-you-do-if-you-build-for-ios">So what do you do if you build for iOS</h2>
<p>Three paths, in my order of preference.</p>
<p><strong>Path A: run the agent on the server, keep iOS as a client (recommended)</strong></p>
<p>ADK for Kotlin&rsquo;s <strong>JVM leg is fully usable.</strong> That is one of its main battlegrounds, not a crippled build.</p>
<p>So run the agent logic on a server JVM and have iOS call it over HTTP. The ADK ecosystem even has the piece for this: the A2A (Agent-to-Agent) protocol, which is exactly what the <code>google-adk-kotlin-a2a</code> module is for (it is also JVM-only, but servers are JVM anyway).</p>
<p>The cost is losing on-device capability. Privacy, offline and low latency no longer all fit.
The upside is you can ship today with zero porting risk.</p>
<p>For most products that trade is worth it: your agent already calls a model over the network, so it was never purely offline.</p>
<p><strong>Path B: write your own &ldquo;ADK-lite for iOS&rdquo;</strong></p>
<p>Take only the design ideas from commonMain — orchestration, sessions, memory, tool schemas — and rebuild them on iOS out of parts the KMP ecosystem already has:</p>
<ul>
<li>Ktor for networking (KMP supports iOS natively)</li>
<li>Call the Gemini REST API directly, bypassing <code>google-genai-kotlin</code></li>
<li>SQLDelight for persistence</li>
</ul>
<p>But then you are not &ldquo;using ADK.&rdquo; You are reimplementing it from its design. Business logic can be shared. The framework gets rewritten.</p>
<p><strong>Path C: wait, or push</strong></p>
<p>1.0 to 1.1.0 was 8 days. Iteration is not slow. If iOS is on the roadmap, waiting pays off.</p>
<p>One thing I did while I was at it: I opened an issue in the repo to get this settled —</p>
<blockquote>
<p><a href="https://github.com/google/adk-kotlin/issues/504">https://github.com/google/adk-kotlin/issues/504</a></p>
</blockquote>
<p>I asked three things: is iOS on the roadmap, what is the actual blocker (I suspect it is the GenAI Kotlin SDK missing a native target, not ADK itself), and what architecture does the team recommend today.</p>
<p>Why I think the issue is worth something: the repo only has a handful of issues total, and one feature request filed last month got a <code>need attention</code> label within two days. The team is watching. If you need iOS, go add a thumbs up. Demand signals beat everything else.</p>
<h2 id="the-method-is-worth-more-than-the-conclusion">The method is worth more than the conclusion</h2>
<p>Back to the method from the top.</p>
<p>Next time you evaluate any KMP library — ADK, or some other &ldquo;cross-platform&rdquo; framework — do not just read the README and release notes. The README will say things like &ldquo;runs on all supported platforms,&rdquo; which looks comprehensive, but who defines &ldquo;supported&rdquo; and how far it goes is usually left out.</p>
<p><strong>Go read the <code>.module</code> file.</strong> It lists what the build system can actually resolve:</p>
<ul>
<li><code>iosArm64</code> / <code>iosSimulatorArm64</code> present → iOS really works</li>
<li><code>js</code> / <code>wasmJs</code> present → web really works</li>
<li>only <code>common</code> / <code>jvm</code> / <code>androidJvm</code> → JVM and Android only</li>
</ul>
<p>One curl saves you two weeks of refactoring.</p>
<p>That is the biggest cognitive trap in KMP these last couple of years: <strong>&ldquo;built on KMP&rdquo; and &ldquo;works on all KMP platforms&rdquo; are different claims.</strong> The first is about how the code is organized. The second is about what you actually get. ADK for Kotlin is a good example — the core really is a carefully designed cross-platform architecture, and right now it chooses to keep the iOS door shut.</p>
<hr>
<p><em>How I verified: read the Gradle module metadata for every version of <code>com.google.adk:google-adk-kotlin-core</code> (including 1.1.0) on Maven Central, the <code>core/build.gradle.kts</code> and <code>core/src/</code> source set layout, the platform variants of <code>com.google.genai:google-genai-kotlin</code>, and cross-checked the release notes for ADK for Kotlin v1.0.0 / v1.0.1 / v1.1.0.</em></p>
]]></content:encoded></item><item><title>KMP's Fourth Leg: Running Inside WebAssembly</title><link>http://mings.in/en/posts/kmp-wasm/</link><pubDate>Mon, 21 Sep 2026 08:25:56 +0800</pubDate><guid>http://mings.in/en/posts/kmp-wasm/</guid><description>Android, iOS, desktop: the three places KMP has covered solidly for years. The fourth leg is WebAssembly, and last week&amp;rsquo;s Chasm 2.0 is on that line.</description><content:encoded><![CDATA[<h2 id="the-kmp-map">The KMP map</h2>
<p>Android, iOS, desktop. Those are the three places KMP has covered solidly for the last few years. The fourth leg is WebAssembly, and last week&rsquo;s Chasm 2.0 release is on that line.</p>
<p>Before looking at it, there is one easy confusion to clear up first, or you will get the whole roadmap wrong.</p>
<h2 id="chasm-a-wasm-runtime-written-in-kotlin">Chasm: a Wasm runtime written in Kotlin</h2>
<p>Its self-description is one line: Chasm is a WebAssembly runtime built on Kotlin Multiplatform. In other words, a WebAssembly runtime built with KMP.</p>
<p>Mind the word order. It is a thing that runs Wasm, written in Kotlin. It is not Kotlin compiled to Wasm. Those two point in opposite directions. More on that shortly.</p>
<p>The project sits at 227 stars now (221 when I wrote the first draft) and supports the Wasm 3.0 spec, minus Memory64 and vector instructions. It implements the Tail Call, Typed Function References, Wasm GC, Multiple Memories, Exception Handling and Wide Arithmetic proposals. A runtime that handles GC and exceptions is not a toy anymore.</p>
<p>2.0 shipped September 16, and the author says plainly that it is full of breaking changes: a new Android target (min SDK 28); the JVM target floor raised to JDK 17; the Host Function API rewritten, from passing a list of values in and returning a list of values out to reading and writing the runtime stack directly; the deprecated macosX64 and iosX64 targets removed; the Gradle plugin no longer supporting AGP 8.</p>
<p>One note on progress: as of writing this (September 21), there are no commits or releases after 2.0. The author is collecting feedback. That is the right pace. A major version just shipped, hold steady before iterating.</p>
<p>What caught my attention most is one trade-off the author spells out:</p>
<blockquote>
<p>I want to do everything within the native constraints of each platform, and that means no FFI or anything like it.</p>
</blockquote>
<p>Anyone who does cross-platform work knows the weight of that sentence. The cheap way to let a JVM call a Wasm runtime is a thin C interface on top. He took the expensive path: a native implementation per platform. The cost is double the work. The payoff is no FFI dependency and no ABI compatibility headaches.</p>
<h2 id="dont-mix-them-up-these-two-point-opposite-ways">Don&rsquo;t mix them up: these two point opposite ways</h2>
<p>I suspect a lot of people see Kotlin and Wasm next to each other and jump straight to: does KMP support Wasm now?</p>
<p>No. These are two things pointing in opposite directions.</p>
<p>Kotlin/Wasm is the official compilation target. Its job is compiling Kotlin source into .wasm and shipping it off to the browser. Direction: outbound.</p>
<p>Chasm does the opposite work: take an existing .wasm file, execute it in its own runtime, running on JVM, Android and iOS. Direction: inbound.</p>
<p>Mix those two up and your understanding of the whole roadmap is off.</p>
<h2 id="the-fourth-leg-means-the-official-one">The fourth leg means the official one</h2>
<p>The official docs are plain about it: Kotlin/Wasm compiles Kotlin to WebAssembly, for Compose Multiplatform web apps or for running in a standalone Wasm VM outside the browser (the latter reaching platform capabilities through WASI).</p>
<p>Two points.</p>
<p>One: the Web platform target for Compose Multiplatform is Kotlin/Wasm. Which means UI written in Compose really does run in a browser, sharing the same UI code with Android, iOS and desktop. That is what the fourth leg actually means.</p>
<p>Two: there are hard browser requirements. It needs the WebAssembly garbage collection (WasmGC) and exception handling proposals. I assumed Safari would be the problem child; after checking the official material, the bar is lower than I expected. The versions:</p>
<table>
	<thead>
			<tr>
					<th>Browser</th>
					<th>Minimum version</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Chrome / Chromium family (Edge, Brave, Opera, Samsung Internet)</td>
					<td>119</td>
			</tr>
			<tr>
					<td>Firefox</td>
					<td>120</td>
			</tr>
			<tr>
					<td>Safari / WebKit</td>
					<td>18.2</td>
			</tr>
			<tr>
					<td>Node (outside the browser)</td>
					<td>22.0</td>
			</tr>
	</tbody>
</table>
<p>The Safari row deserves its own explanation. 18.2 landed at the end of 2024, shipping as iOS 18.2 / macOS 15.2. On iOS and iPadOS, Safari rides along with the OS; users do not update it separately. So translated, that requirement is not don&rsquo;t use Safari, it is don&rsquo;t let your users sit on ancient hardware. Get that one wrong and the whole technology decision flips.</p>
<p>One more detail worth knowing: the exception handling proposal now comes in an old and a new version. The Kotlin toolchain supports both, but the <code>wasmJs</code> target uses the legacy one by default, for wider compatibility. Switching to the new one takes explicit configuration.</p>
<h2 id="measured-what-the-wasm-side-costs">Measured: what the Wasm side costs</h2>
<p>Measured on a real KMP project on this machine, with both a JS and a Wasm target. First the numbers from my first draft, on Kotlin 2.4.0 + CMP 1.11.1:</p>
<pre tabindex="0"><code>$ ./gradlew :app:webApp:wasmJsBrowserDistribution
→ BUILD SUCCESSFUL in 4m 10s
→ 50 actionable tasks: 50 executed
</code></pre><p>Four minutes ten, and that is with dependencies already cached.</p>
<p>The artifacts:</p>
<table>
	<thead>
			<tr>
					<th>File</th>
					<th>Size</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>6e23e542&hellip;wasm (skiko rendering)</td>
					<td>8.25 MiB</td>
			</tr>
			<tr>
					<td>212a0e38&hellip;wasm (application code)</td>
					<td>5.97 MiB</td>
			</tr>
			<tr>
					<td>webApp.js</td>
					<td>529 KiB</td>
			</tr>
			<tr>
					<td>index.html + styles.css</td>
					<td>about 1 KB</td>
			</tr>
	</tbody>
</table>
<p>The two wasm files add up to 14.2 MiB; the whole directory is about 15MB.</p>
<p>Then I temporarily bumped the project to <strong>Kotlin 2.4.20 + CMP 1.12.0</strong> and ran it again:</p>
<pre tabindex="0"><code>$ ./gradlew :app:webApp:wasmJsBrowserDistribution
→ BUILD SUCCESSFUL in 3m 33s
→ 50 actionable tasks: 48 executed, 2 up-to-date
</code></pre><table>
	<thead>
			<tr>
					<th></th>
					<th>Kotlin 2.4.0 + CMP 1.11.1</th>
					<th>Kotlin 2.4.20 + CMP 1.12.0</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Build time</td>
					<td>4m 10s</td>
					<td><strong>3m 33s</strong></td>
			</tr>
			<tr>
					<td>skiko wasm</td>
					<td>8.25 MiB</td>
					<td>8.24 MiB</td>
			</tr>
			<tr>
					<td>app wasm</td>
					<td>5.97 MiB</td>
					<td><strong>5.67 MiB</strong></td>
			</tr>
			<tr>
					<td>webApp.js</td>
					<td>529 KiB</td>
					<td>526 KiB</td>
			</tr>
			<tr>
					<td>wasm total</td>
					<td>14.22 MiB</td>
					<td><strong>13.91 MiB</strong></td>
			</tr>
	</tbody>
</table>
<p>Not dramatic, but the direction is clear: builds half a minute faster, app wasm down 0.30 MiB, about 5%.</p>
<p><strong>I should be clear that this is not a strictly even comparison.</strong> Two tasks hit cache on the second run, so part of that 3m33s is savings rather than the compiler getting faster. But the conclusion that this line keeps getting faster and smaller holds.</p>
<p>Compare the desktop numbers, packaged for JVM: 133MB, roughly nine times as much. The reason is simple. Of those 133M, 77M is the JVM runtime and 20M is Skia. In the browser neither gets bundled. No JVM, and rendering goes straight to the browser&rsquo;s Canvas and WebGL.</p>
<p>That is the most concrete benefit of the Wasm side: you do not carry the runtime.</p>
<p>webpack does throw a string of warnings during the build, saying several assets exceed the recommended 244 KiB ceiling. It names webApp.js at 529 KiB and both wasm files. 8.25 MiB is over thirty times its suggestion. Meaning: without code splitting and lazy loading, a user downloads 15MB on first open. On a weak connection that is a real experience problem.</p>
<h2 id="what-the-web-side-picked-up-this-month">What the Web side picked up this month</h2>
<p>While bumping versions I went through two sets of release notes. A few items matter a lot if you build Chinese-language apps, so they get their own section.</p>
<p><strong>Web changes in CMP 1.12 (August 25)</strong>:</p>
<ul>
<li><strong>Automatic Noto font loading.</strong> When a glyph is not covered by the theme font, it loads Noto on demand. Anyone building Chinese or multilingual apps knows what that means: rare characters used to render as tofu blocks</li>
<li>Two tofu fixes specifically: unknown glyphs on a text field&rsquo;s first draw, and tofu on the second and later draws of the same text</li>
<li>Fixed a bug where <code>div#cmp_a11y_root</code> was sized 0×0. That one is serious: it made everything Compose rendered invisible to hit-test based accessibility tools</li>
<li>Wheel events now carry both axes, which fixes diagonal scrolling. Precise trackpad gesture detection on Chrome. Fixed pointer state left dangling after <code>pointercancel</code>, which system gestures, palm rejection and device disconnects all trigger</li>
<li>Also fixed: focusing a text field on a mobile browser could hide the soft keyboard</li>
<li>The web target also picked up haptic feedback support and <code>Modifier.keepScreenOn</code></li>
</ul>
<p><strong>CMP 1.13.0-alpha01 (September 10)</strong>:</p>
<ul>
<li>The deprecated <code>compose.web.targets(...)</code> Gradle plugin API is gone. If it is still in an old build script, the upgrade fails outright</li>
<li>Experimental support for system window insets on the web</li>
<li>A new experimental API for rendering WebGL textures</li>
<li>When the browser or device lacks WebGL2, you get a message you can understand instead of an opaque crash</li>
</ul>
<p><strong>Kotlin 2.4.20 (September 7) moved the Wasm backend a lot more.</strong> This release notes page has sixty-odd Wasm entries. Three you will hit sooner or later:</p>
<ul>
<li><strong>Range checks are on by default.</strong> Better safety, a bit of performance in exchange</li>
<li><strong><code>wasmExports</code> is deprecated and banned.</strong> If you reach into wasm exports directly through JS interop, the upgrade breaks</li>
<li>Prototyping suspend functions generated via the stack-switching proposal. Not usable yet, but worth remembering the direction. It is the official path for async in Wasm</li>
</ul>
<p>One upgrade caveat: refactoring callable references and generating a unified fun interface base class <strong>broke klib binary compatibility</strong>. If your dependencies include someone else&rsquo;s published wasmJs klib, they may need to republish. This kind of break is far more sensitive in the multiplatform ecosystem than on the JVM. Run a build before upgrading; do not find out at release time.</p>
<h2 id="where-it-fits-where-it-doesnt">Where it fits, where it doesn&rsquo;t</h2>
<p>The best deal is when you already have a KMP project and want a web client for free. Same as desktop: add a target. Internal tools and admin backends work too. You do not care about a 15MB first load, you care about one codebase.</p>
<p>Where it really pays off is logic-heavy apps with relatively fixed UI. The more business logic you share, the better the math.</p>
<p>Going the other way: consumer marketing pages and lightweight sites, do not bother. A 15MB first load plus old-browser incompatibility is a bounce. SEO-dependent products are out too, because crawlers cannot see content rendered by Wasm. If Web is your only platform, plain React or Vue is less trouble.</p>
<p>The test compresses to one question: is your core value the Kotlin code you already wrote? If yes, use Wasm. If not, do not force it in the name of technology uniformity.</p>
<h2 id="appendix-a-minimal-working-project">Appendix: a minimal working project</h2>
<p>Three steps to a running app.</p>
<p>Step one, add the target in build.gradle.kts:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">plugins</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">alias</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">plugins</span><span class="p">.</span><span class="n">kotlinMultiplatform</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">alias</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">plugins</span><span class="p">.</span><span class="n">composeMultiplatform</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">alias</span><span class="p">(</span><span class="n">libs</span><span class="p">.</span><span class="n">plugins</span><span class="p">.</span><span class="n">composeCompiler</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@OptIn</span><span class="p">(</span><span class="n">org</span><span class="p">.</span><span class="n">jetbrains</span><span class="p">.</span><span class="n">kotlin</span><span class="p">.</span><span class="n">gradle</span><span class="p">.</span><span class="n">ExperimentalWasmDsl</span><span class="o">::</span><span class="k">class</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">wasmJs</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">browser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="n">binaries</span><span class="p">.</span><span class="n">executable</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">sourceSets</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">commonMain</span><span class="p">.</span><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">implementation</span><span class="p">(</span><span class="n">compose</span><span class="p">.</span><span class="n">runtime</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">implementation</span><span class="p">(</span><span class="n">compose</span><span class="p">.</span><span class="n">foundation</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">implementation</span><span class="p">(</span><span class="n">compose</span><span class="p">.</span><span class="n">material3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Step two, write the entry point at src/wasmJsMain/kotlin/main.kt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">androidx.compose.ui.ExperimentalComposeUiApi</span>
</span></span><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">androidx.compose.ui.window.ComposeViewport</span>
</span></span><span class="line"><span class="cl"><span class="k">import</span> <span class="nn">kotlinx.browser.document</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@OptIn</span><span class="p">(</span><span class="n">ExperimentalComposeUiApi</span><span class="o">::</span><span class="k">class</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">ComposeViewport</span><span class="p">(</span><span class="n">document</span><span class="p">.</span><span class="n">body</span><span class="o">!!</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">App</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Step three, put the UI in commonMain, shared by all three platforms:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@Composable</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">App</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">MaterialTheme</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">var</span> <span class="py">count</span> <span class="k">by</span> <span class="n">remember</span> <span class="p">{</span> <span class="n">mutableStateOf</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="n">Column</span><span class="p">(</span><span class="nc">Modifier</span><span class="p">.</span><span class="n">padding</span><span class="p">(</span><span class="mi">24</span><span class="p">.</span><span class="n">dp</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">Text</span><span class="p">(</span><span class="s2">&#34;Hello, Wasm!&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">Button</span><span class="p">(</span><span class="n">onClick</span> <span class="p">=</span> <span class="p">{</span> <span class="n">count</span><span class="o">++</span> <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">Text</span><span class="p">(</span><span class="s2">&#34;点了 </span><span class="si">$count</span><span class="s2"> 次&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Run it:</p>
<pre tabindex="0"><code>./gradlew wasmJsBrowserDevelopmentRun   # dev mode, with hot reload
./gradlew wasmJsBrowserDistribution     # production bundle
</code></pre><p>ComposeViewport is Compose Multiplatform&rsquo;s mount point on Web/Wasm, roughly the same role as setContent on Android or application on desktop. So the integration cost of the fourth leg is one target and one entry function, with the UI code still shared.</p>
<h2 id="how-to-use-chasm">How to use Chasm</h2>
<p>Back to the project from the opening. Here is what using it looks like, the minimal example from the README:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">plugins</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">id</span><span class="p">(</span><span class="s2">&#34;io.github.charlietap.chasm.gradle&#34;</span><span class="p">)</span> <span class="n">version</span> <span class="s2">&#34;3.0.0-2.0.0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">chasm</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">modules</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">create</span><span class="p">(</span><span class="s2">&#34;ExampleService&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">binary</span> <span class="p">=</span> <span class="n">layout</span><span class="p">.</span><span class="n">projectDirectory</span><span class="p">.</span><span class="k">file</span><span class="p">(</span><span class="s2">&#34;src/main/resources/example.wasm&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">packageName</span> <span class="p">=</span> <span class="s2">&#34;com.example&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>With that in place, the build reads your .wasm file and generates a Kotlin class, ExampleService, where every exported function becomes a Kotlin function you can call directly. You use an existing Wasm module like an ordinary Kotlin library.</p>
<p>Underneath is a single Wasm VM abstraction: on JVM and native platforms it uses Chasm&rsquo;s own implementation, in the browser it reuses the host&rsquo;s built-in VM.</p>
<p>What does that solve? Say you have a C/C++/Rust library compiled to Wasm and you want it inside an iOS app. That used to mean writing FFI glue. Now you call it as a Kotlin library. Or take a cross-platform plugin system, where plugins ship as Wasm and the host executes them.</p>
<p>That is the real reason Chasm deserves its own section. It shows the other half of what Wasm can do.</p>
<h2 id="my-take">My take</h2>
<p>Chasm and Kotlin/Wasm in the news at the same time is a good thing. One sends Kotlin out, one brings Wasm in; they connect the two from opposite directions. Two-way connection like that means the ecosystem is growing some depth.</p>
<p>The real barrier for Wasm is not technology, it is size and compatibility. A 15MB bundle, browser requirements for GC and exception handling: those are hard commercial constraints, not engineering fastidiousness. When I measured that 4m10s build time I thought, this technology is mature now. But mature does not mean right for your product.</p>
<p>One more thing: these four KMP surfaces are nowhere near equal. Android and iOS have a decade behind them, desktop five or six years, Web/Wasm two or three. Holding them to the same standard is unreasonable. The smoothness you are used to on Android will mostly need re-learning on Wasm.</p>
<p>If you want a web client, my advice is to add a Wasm target to your existing project first and run a Hello World. The config above, half an hour to verify. The important part is in your existing project. You will hit real problems immediately: whether your dependencies publish wasmJs variants (the most common blocker), whether your expect/actual declarations miss a wasmJs branch, how much build time you add. A toy project will not show you any of that; only real dependencies expose it. Half an hour hitting the wall is a much better trade than two weeks of refactoring followed by discovering the library does not support it.</p>
<p>Will you add a web client to your KMP project? Is a 15MB first load acceptable? Comments are open.</p>
<hr>
<p><em>Sources: the CharlieTap/chasm repo and the 2.0.0 release notes; the official Kotlin docs on Kotlin/Wasm and &ldquo;Supported versions and configuration&rdquo;; the official WebAssembly roadmap for GC support versions; the Compose Multiplatform 1.12.0 and 1.13.0-alpha01 release notes; the Kotlin 2.4.20 release notes; a Reddit r/Kotlin thread. Test environment: Mac mini (M4, 16GB), project is a KMP app with both JS and Wasm targets, built once under Kotlin 2.4.0 + CMP 1.11.1 and once under Kotlin 2.4.20 + CMP 1.12.0 (the upgrade was temporary; the project was restored afterward).</em></p>
]]></content:encoded></item><item><title>M6 Benchmarks Match M3 Max — Is KMP Desktop Worth Betting On?</title><link>http://mings.in/en/posts/m6-kmp-desktop/</link><pubDate>Sun, 20 Sep 2026 08:33:10 +0800</pubDate><guid>http://mings.in/en/posts/m6-kmp-desktop/</guid><description>Apple&amp;rsquo;s M6 benchmarks have been trickling out the last couple of days. Single-core about 24% above the M4 part, multi-core about 48%, and multi-core straight up matches M3 Max — a mid-range chip tying the previous flagship on multi-core is genuinely wild.</description><content:encoded><![CDATA[<h2 id="the-benchmarks-are-genuinely-wild">The benchmarks are genuinely wild</h2>
<p>Apple&rsquo;s M6 benchmarks have been trickling out the last couple of days. Single-core about 24% above the M4 part, multi-core about 48%, and multi-core straight up matches M3 Max — a mid-range chip tying the previous flagship on multi-core is genuinely wild.</p>
<p>On the GPU side, 12 cores pulled 4,071 single-core and 22,783 multi-core on Geekbench, roughly 20% above M5.</p>
<p>And then I started seeing people discuss it: chips are this fast now, should we build out desktop too?</p>
<p>So I tried it on a project of my own. The conclusion is a bit of a letdown: this has basically nothing to do with chip benchmarks.</p>
<h2 id="what-i-actually-ran">What I actually ran</h2>
<p>The machine is a Mac mini, M4, 10 cores, 16G of RAM. The project is a KMP + Compose Multiplatform AI chat client, with shared holding the shared logic and desktopApp as the desktop entry point.</p>
<p>Packaging is one command:</p>
<pre tabindex="0"><code>./gradlew :desktopApp:createDistributable
</code></pre><p>Ran it twice: 22 seconds the first time, 8 the second. Both fast — but dependencies were already downloaded by then and the code hadn&rsquo;t changed much. The real first cold build (fetch dependencies, compile Kotlin, generate the runtime image) is measured in minutes.</p>
<p>Fast is fast, but that&rsquo;s not the point. The last number is:</p>
<pre tabindex="0"><code>$ du -sh lange.aichat.app
133M
</code></pre><p>One hundred and thirty-three megabytes. For a chat client.</p>
<p>It gets interesting when you break it open:</p>
<table>
	<thead>
			<tr>
					<th>Directory</th>
					<th>Size</th>
					<th>What&rsquo;s in it</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Contents/runtime</td>
					<td>77M</td>
					<td>The bundled JRE</td>
			</tr>
			<tr>
					<td>├ lib/modules</td>
					<td>52M</td>
					<td>JRE core modules</td>
			</tr>
			<tr>
					<td>└ libjvm.dylib</td>
					<td>17M</td>
					<td>The JVM itself</td>
			</tr>
			<tr>
					<td>Contents/app</td>
					<td>55M</td>
					<td>App and dependencies</td>
			</tr>
			<tr>
					<td>├ libskiko-macos-arm64.dylib</td>
					<td>20M</td>
					<td>Skia rendering engine</td>
			</tr>
			<tr>
					<td>├ skiko-awt-runtime</td>
					<td>8.9M</td>
					<td>Skiko&rsquo;s JVM bindings</td>
			</tr>
			<tr>
					<td>├ material3-desktop</td>
					<td>4.4M</td>
					<td></td>
			</tr>
			<tr>
					<td>├ foundation-desktop</td>
					<td>4.4M</td>
					<td></td>
			</tr>
			<tr>
					<td>└ ui-desktop / runtime / kotlin-stdlib</td>
					<td>3.3 / 1.9 / 1.8M</td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>77M is the JVM itself, plus 20M of Skia, so the floor just to make the thing run is 97M. The part you wrote yourself is maybe a few hundred K. And that 77M is already after one pass through jlink, not the raw size.</p>
<p>So the account is clear: Compose Desktop is pleasant to write, but the distribution cost is sitting right there. Unless you replace the JVM premise (the GraalVM native-image road), a hundred-odd megabytes is the norm.</p>
<h2 id="if-m6-is-this-much-faster-where-does-it-actually-help">If M6 is this much faster, where does it actually help</h2>
<p>Compilation. Kotlin compilation, Gradle parallel tasks, native linking — these are all multi-core work, and 48% more multi-core genuinely shortens build times.</p>
<p>I also measured another target in the same repo while I was at it:</p>
<pre tabindex="0"><code>./gradlew :app:webApp:wasmJsBrowserDistribution
→ BUILD SUCCESSFUL in 4m 10s
</code></pre><p>4 minutes 10 seconds, all 50 tasks run, CPU and memory pegged. For anyone running several builds a day, this is the multi-core gain you feel most directly.</p>
<p>And end users?</p>
<p>For a chat client, the bottlenecks users can feel are these: cold start — JVM loading plus Compose init, which is single-core and IO, so multi-core barely helps; scrolling and animation — that&rsquo;s Skia, and 20% more GPU is overkill for a 2D UI; first screen — that&rsquo;s the network.</p>
<p>M6 could be twice as fast and users still wouldn&rsquo;t feel your app get faster.</p>
<h2 id="so-should-you-build-the-desktop-app-or-not">So should you build the desktop app or not</h2>
<p>Here&rsquo;s how I see it.</p>
<p>If you already have a KMP project and want one more platform, just add it. The shared logic is already there, it&rsquo;s one more entry point. Internal tools and developer tools too — you&rsquo;re only installing it on your own team&rsquo;s machines, nobody cares how big the bundle is.</p>
<p>What genuinely deserves its own project is a different class of need: reading and writing files locally, multiple windows that stay resident in the background, running models on the machine. Those are things desktop can do and phones can&rsquo;t. In that case 133M is nothing — users are installing a tool, not a toy.</p>
<p>Conversely, be careful with lightweight consumer utilities. Nobody downloads 133M for a little weather app. Same for hard cold-start requirements: you can&rsquo;t route around the JVM cold-start curve, unless you go GraalVM.</p>
<p>Side note, Compose Multiplatform 1.13 is already working on AppCDS and AOT (the 1.13.0-alpha01 notes mention &ldquo;significantly faster app startup&rdquo;). If you&rsquo;re hesitating right now because startup is slow, you can wait one or two releases.</p>
<h2 id="last">Last</h2>
<p>M6 is great. Builds get faster, and for people typing code every day that&rsquo;s a real gain.</p>
<p>But it doesn&rsquo;t change the desktop trade-off. What holds you back is bundle size and cold start, and the chip has nothing to do with either. If your motivation is &ldquo;capture the new hardware dividend&rdquo;, you may be looking in the wrong place — the real desktop dividend is in packaging technology (jlink, AppCDS, GraalVM), not CPU benchmarks.</p>
<p>If you&rsquo;re already doing KMP, desktop is actually the cheapest end to add: shared is written, add an entry point and you have one more platform. That reason has nothing to do with chips, and I think it&rsquo;s more solid than a benchmark score.</p>
<p>Would you add desktop to your project? How big a bundle can you live with? Comments are open.</p>
<hr>
<p><em>Test environment: Mac mini (Apple M4, 10 cores, 16GB), macOS 27.0, JDK 21.0.10, Kotlin 2.4.0 + Compose Multiplatform 1.11.x. M6 scores come from leaked Geekbench results and public reporting from cnBeta, Notebookcheck, Phoenix Tech and others.</em></p>
]]></content:encoded></item><item><title>AGENTS.md Is Here. So How Should a KMP Project Write This Manual?</title><link>http://mings.in/en/posts/agents-md-kmp/</link><pubDate>Sat, 19 Sep 2026 07:19:11 +0800</pubDate><guid>http://mings.in/en/posts/agents-md-kmp/</guid><description>Starting with yesterday&amp;rsquo;s news: Claude Code 2.1.277 now supports AGENTS.md (September 18).</description><content:encoded><![CDATA[<h2 id="a-standoff-that-lasted-nearly-a-year-ended-yesterday">A standoff that lasted nearly a year ended yesterday</h2>
<p>Starting with yesterday&rsquo;s news: <strong>Claude Code 2.1.277 now supports AGENTS.md</strong> (September 18).</p>
<p>The Register&rsquo;s headline was blunt: <strong>&ldquo;Anthropic decides to support OpenAI&rsquo;s markdown instruction spec.&rdquo;</strong></p>
<p>If you don&rsquo;t live inside AI coding tools every day, you might think: it&rsquo;s a config file name. Who cares?</p>
<p><strong>It matters. Because this whole standoff started with a hard line the Shopify CEO said in public.</strong></p>
<p>Rewind to August 26. Shopify CEO <strong>Tobi Lütke</strong> posted on X:</p>
<blockquote>
<p>I am considering banning Claude Code at Shopify <strong>until they are willing to read AGENTS.md, .agents/skills and similar files.</strong></p>
</blockquote>
<p>An e-commerce giant threatening to ban a mainstream AI coding tool company-wide — <strong>over nothing more than its refusal to read one markdown file.</strong></p>
<p>And the reason he gave is, to me, worth more than the news itself:</p>
<blockquote>
<p>Both AGENTS.md and CLAUDE.md apply recursively down the directory tree. When thousands of developers maintain one monorepo, <strong>some directory will inevitably be missing a file, and the result is that some developers end up with an agent that has had a chunk of its brain cut out.</strong></p>
<p>We solved it with automation, but that is a <strong>stupid complexity tax</strong>, and we should not have to pay it at all.</p>
</blockquote>
<p><strong>&ldquo;An agent with a chunk of its brain cut out&rdquo;</strong> — if you have ever maintained a large repo, that one sinks in.</p>
<h2 id="first-what-agentsmd-actually-is">First, what AGENTS.md actually is</h2>
<p>The official definition is one sentence:</p>
<blockquote>
<p><strong>Think of AGENTS.md as a README for agents.</strong></p>
</blockquote>
<p><strong>A README for agents.</strong></p>
<p>A README is for humans: how to get up and running fast, what the project does. AGENTS.md holds what the agent needs but does not belong in a README: <strong>build steps, how to run tests, code conventions, a few hard rules.</strong></p>
<p>Its standing today is higher than a lot of people realize:</p>
<ul>
<li>Adopted by <strong>60,000+ open source projects</strong></li>
<li>Started as a collaboration between several: <strong>OpenAI Codex, Amp, Google&rsquo;s Jules, Cursor, Factory</strong></li>
<li>Now stewarded by the <strong>Agentic AI Foundation under the Linux Foundation</strong></li>
</ul>
<p>The minimal example from the official docs looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># AGENTS.md
</span></span></span><span class="line"><span class="cl"><span class="gu">## Setup commands
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Install deps: <span class="sb">`pnpm install`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Start dev server: <span class="sb">`pnpm dev`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Run tests: <span class="sb">`pnpm test`</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Code style
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> TypeScript strict mode
</span></span><span class="line"><span class="cl"><span class="k">-</span> Single quotes, no semicolons
</span></span></code></pre></div><p><strong>That is all it is.</strong> No schema, no special syntax. Just markdown.</p>
<p>What makes it actually useful: <strong>one file works across multiple agents</strong>. Codex, Cursor, Jules, Copilot and Claude Code all read it, so you are not maintaining a config per tool.</p>
<blockquote>
<p>Side note: Claude Code insisted on its own <code>CLAUDE.md</code> for a long time. Developers started asking in August 2025, the issue collected nearly <strong>5,000 GitHub reactions</strong>, and it was finally closed as won&rsquo;t fix. <strong>It took Shopify threatening a ban to get support on September 18.</strong></p>
</blockquote>
<h2 id="why-one-file-escalates-into-a-platform-engineering-problem">Why one file escalates into a platform engineering problem</h2>
<p>Plenty of people will think: it is just getting Claude Code to read one more file. Why would Shopify escalate to a company-wide ban?</p>
<p><strong>Because in a monorepo with thousands of people, one file format discrepancy turns into chaos you cannot control.</strong></p>
<p>The mechanism Lütke described is worth copying down:</p>
<pre tabindex="0"><code>monorepo/
├── AGENTS.md          ← global rules (tech stack, commit conventions)
├── backend/
│   └── AGENTS.md      ← backend-specific (do not touch db/migrations)
├── mobile/
│   └── AGENTS.md      ← mobile-specific (packaging, signing)
└── infra/
    └── AGENTS.md      ← infrastructure-specific
</code></pre><p><strong>AGENTS.md applies nearest-first</strong> — the agent reads the closest one in the current directory and inherits the rules above it.</p>
<p>That is where the trouble starts:</p>
<ul>
<li>Say the team uses both Cursor (reads AGENTS.md) and Claude Code (only reads CLAUDE.md)</li>
<li>Someone writes <code>AGENTS.md</code> under <code>mobile/</code>, and nobody remembers to write a <code>CLAUDE.md</code> too</li>
<li>Developer on Cursor: the agent knows to run the signing script before packaging</li>
<li>Developer on Claude Code: the agent <strong>does not</strong></li>
</ul>
<p><strong>Same code, same task, two different results.</strong> And no alert will ever catch it — <strong>you just occasionally notice that a colleague&rsquo;s AI is &ldquo;especially dumb.&rdquo;</strong></p>
<p><strong>That is the complexity tax Lütke was talking about</strong>: not that it cannot be solved, but that <strong>the problem should not exist in the first place.</strong></p>
<h2 id="down-to-practice-writing-one-for-a-kmp--ai-chat-client">Down to practice: writing one for a KMP + AI chat client</h2>
<p>Okay, news done. On to the actual subject.</p>
<p>Say we are building a <strong>Kotlin Multiplatform + Compose Multiplatform AI chat client</strong> — one Kotlin codebase running on Android and iOS.</p>
<p>A project like this has its own quirks when writing AGENTS.md: <strong>in one repo, three kinds of code follow completely different rules.</strong></p>
<p>Start with the directory tree:</p>
<pre tabindex="0"><code>ai-chat/
├── AGENTS.md                      # root: global hard rules + common commands
├── settings.gradle.kts
├── gradle/
│   └── libs.versions.toml         # version catalog (single source for dependency versions)
├── shared/                        # shared business logic (no UI)
│   ├── AGENTS.md                  # rules for this layer
│   ├── build.gradle.kts
│   └── src/
│       ├── commonMain/kotlin/     # ← shared across platforms
│       ├── androidMain/kotlin/    # ← Android implementation
│       ├── iosMain/kotlin/        # ← iOS implementation
│       └── commonTest/kotlin/
├── composeApp/                    # Compose Multiplatform UI
│   ├── AGENTS.md
│   └── src/
│       ├── commonMain/
│       ├── androidMain/
│       └── iosMain/
├── androidApp/                    # Android shell project
│   ├── AGENTS.md
│   └── src/main/AndroidManifest.xml
└── iosApp/                        # iOS shell project (Xcode)
    ├── AGENTS.md
    └── iosApp.xcodeproj
</code></pre><p><strong>Note: the structure itself is the first layer of information for the agent.</strong> If the agent cannot tell <code>shared/</code> from <code>composeApp/</code>, it will write UI code into the logic layer.</p>
<h3 id="root-agentsmd-only-global-hard-rules-and-common-commands">Root AGENTS.md: only global hard rules and common commands</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># AGENTS.md
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">An AI chat client built with KMP + Compose Multiplatform.
</span></span><span class="line"><span class="cl">One Kotlin codebase, compiled to Android / iOS.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Project structure
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`shared/`</span>     — shared business logic, <span class="gs">**contains no UI**</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`composeApp/`</span> — Compose Multiplatform UI (all platforms share one set of screens)
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`androidApp/`</span> — Android shell (Activity / Application / manifest)
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`iosApp/`</span>     — iOS shell (Xcode project / SwiftUI entry point)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Build and test
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Full build:             <span class="sb">`./gradlew build`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Shared module compile:  <span class="sb">`./gradlew :shared:compileKotlinMetadata`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Install to Android:     <span class="sb">`./gradlew :androidApp:installDebug`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Run all tests:          <span class="sb">`./gradlew allTests`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Run one class:          <span class="sb">`./gradlew :shared:allTests --tests &#34;*ChatRepository*&#34;`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> iOS framework:          <span class="sb">`./gradlew :shared:linkDebugFrameworkIosSimulatorArm64`</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Hard rules (violating these breaks compilation on every target)
</span></span></span><span class="line"><span class="cl"><span class="k">1.</span> <span class="sb">`java.*`</span>, <span class="sb">`android.*`</span>, <span class="sb">`platform.*`</span> are <span class="gs">**forbidden**</span> in <span class="sb">`commonMain`</span>
</span></span><span class="line"><span class="cl"><span class="k">2.</span> All platform differences go through <span class="sb">`expect`</span> / <span class="sb">`actual`</span>, never <span class="sb">`if (isAndroid)`</span>
</span></span><span class="line"><span class="cl"><span class="k">3.</span> <span class="gs">**Do not copy the logic for iOS**</span> — that throws away the point of using KMP
</span></span><span class="line"><span class="cl"><span class="k">4.</span> New dependencies go into <span class="sb">`gradle/libs.versions.toml`</span> first; modules reference only the alias, <span class="gs">**never hardcode a version**</span>
</span></span><span class="line"><span class="cl"><span class="k">5.</span> After changing <span class="sb">`commonMain`</span>, it must still compile for both Android and iOS
</span></span></code></pre></div><p><strong>A few things about how this is written that I think matter a lot:</strong></p>
<p><strong>① Commands must be copy-pasteable, not descriptions.</strong>
Write <code>./gradlew :shared:compileKotlinMetadata</code>, not &ldquo;compile the shared module.&rdquo; <strong>Give the agent something it can run directly and it does not have to guess.</strong></p>
<p><strong>② Hard rules need their consequences spelled out.</strong>
&ldquo;No <code>java.*</code> in commonMain&rdquo; is a rule. Add &ldquo;<strong>it breaks compilation on every target</strong>&rdquo; and the agent understands it is a hard constraint, not a style preference.</p>
<p><strong>③ Kill ambiguity with a single source of truth.</strong>
&ldquo;Versions live only in <code>libs.versions.toml</code>&rdquo; — that one line stops the agent from hardcoding versions all over the place.</p>
<h3 id="sharedagentsmd-only-what-is-specific-to-this-layer">shared/AGENTS.md: only what is specific to this layer</h3>
<p><strong>A subdirectory AGENTS.md should not repeat the root file.</strong> It should hold only what you need to know once you are in that directory:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># shared/ — shared business logic
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## What each source set is for
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`commonMain/`</span> — platform-agnostic logic. Need a platform capability, declare it here with <span class="sb">`expect`</span> first
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`androidMain/`</span> — the Android implementation of each <span class="sb">`expect`</span> (Android SDK / JVM APIs)
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`iosMain/`</span>     — the iOS implementation of each <span class="sb">`expect`</span> (Kotlin/Native + Foundation)
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Every new <span class="sb">`expect`</span> needs both <span class="sb">`actual`</span> implementations, in androidMain and iosMain**
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Directory conventions
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`data/`</span>      — repositories, data sources, DTOs
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`domain/`</span>    — domain models, use cases
</span></span><span class="line"><span class="cl"><span class="k">-</span> <span class="sb">`platform/`</span>  — platform abstractions behind expect/actual
</span></span><span class="line"><span class="cl">                 (KeyValueStore, HttpEngine, AppInfo…)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Networking (the core of an AI chat app)
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Use <span class="gs">**Ktor**</span> everywhere. No OkHttp or URLSession directly in the shared layer
</span></span><span class="line"><span class="cl"><span class="k">-</span> Streaming responses always return <span class="sb">`Flow&lt;ChatChunk&gt;`</span>. Do not manage UI state in shared
</span></span><span class="line"><span class="cl"><span class="k">-</span> Timeouts, retries and error mapping all live in <span class="sb">`data/remote/`</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Commands
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Tests:        <span class="sb">`./gradlew :shared:allTests`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> iOS framework: <span class="sb">`./gradlew :shared:linkDebugFrameworkIosSimulatorArm64`</span>
</span></span></code></pre></div><p><strong>See what is in there? This layer holds purely KMP-specific knowledge:</strong></p>
<ul>
<li>the pairing rules for <code>expect</code>/<code>actual</code> (the easiest place to screw up in KMP)</li>
<li>why networking has to be Ktor (OkHttp does not exist on iOS)</li>
<li>why streaming uses <code>Flow</code> (the defining feature of an AI chat app)</li>
</ul>
<p><strong>None of this goes in a general README, and the agent trips on it every single time.</strong></p>
<h3 id="the-shell-projects-each-one-says-what-not-to-touch">The shell projects: each one says what not to touch</h3>
<p><strong>androidApp/AGENTS.md</strong>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># androidApp/ — Android shell
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## What goes here
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Activities, Application, AndroidManifest.xml, res/ resources
</span></span><span class="line"><span class="cl"><span class="k">-</span> **Business logic always goes in shared/, UI always goes in composeApp/**
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Common commands
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Install:  <span class="sb">`./gradlew :androidApp:installDebug`</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Logs:     <span class="sb">`adb logcat -s AiChat:V`</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Watch out
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> New permissions require an AndroidManifest.xml change and <span class="gs">**an update to this file**</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Before changing minSdk, check API compatibility for what shared/ uses
</span></span></code></pre></div><p><strong>iosApp/AGENTS.md</strong>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gh"># iosApp/ — iOS shell (Xcode project)
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Important: this is not built with Gradle
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> This is an Xcode project. <span class="gs">**Do not try to build it with gradlew**</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> After changing code in shared/, run this first:
</span></span><span class="line"><span class="cl">    <span class="sb">`./gradlew :shared:linkDebugFrameworkIosSimulatorArm64`</span>
</span></span><span class="line"><span class="cl">  so Xcode picks up the new framework, then Run from Xcode
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## The Swift side is glue only
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Lifecycle, permission requests, navigation containers
</span></span><span class="line"><span class="cl"><span class="k">-</span> Business logic <span class="gs">**always goes back to shared**</span>. Never rewrite it in Swift
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Common traps
</span></span></span><span class="line"><span class="cl"><span class="k">-</span> Missing permission description in Info.plist: only fails at runtime on the simulator, Xcode says nothing
</span></span><span class="line"><span class="cl"><span class="k">-</span> After changing framework exports, Clean Build Folder
</span></span><span class="line"><span class="cl"><span class="k">-</span> Kotlin sealed classes must be exhaustively switched on in Swift (write every branch, no default)
</span></span></code></pre></div><p><strong>Why three separate files?</strong></p>
<p>Because <strong>what to do and what not to do flip completely from one directory to the next:</strong></p>
<ul>
<li>In <code>shared/</code>, you write: do not touch platform APIs</li>
<li>In <code>androidApp/</code>, you write: do not put business logic here</li>
<li>In <code>iosApp/</code>, you write: do not build this directory with Gradle</li>
</ul>
<p><strong>One repo, rules that contradict each other.</strong> A single root AGENTS.md cannot hold this — <strong>that is exactly why nearest-first exists.</strong></p>
<h3 id="side-note-mixed-tools-on-the-team">Side note: mixed tools on the team</h3>
<p>AGENTS.md is only a convention, and different tools still read it differently. The docs give some config pointers:</p>
<ul>
<li><strong>Aider</strong>: add one line to <code>.aider.conf.yml</code> — <code>read: AGENTS.md</code></li>
<li><strong>Gemini CLI</strong>: configure <code>{ &quot;context&quot;: { &quot;fileName&quot;: &quot;AGENTS.md&quot; } }</code> in <code>.gemini/settings.json</code></li>
<li><strong>Migrating from the old name</strong>: <code>mv AGENT.md AGENTS.md &amp;&amp; ln -s AGENTS.md AGENT.md</code> (the symlink keeps compatibility)</li>
</ul>
<p>In a KMP project there is also the case of <strong>uneven tool versions</strong> — someone&rsquo;s Claude Code is still below 2.1.277. You can drop a <code>CLAUDE.md -&gt; AGENTS.md</code> symlink in the key directories as a stopgap.</p>
<p><strong>But remember what Lütke said: that is paying the complexity tax.</strong></p>
<p><strong>If you can upgrade everyone&rsquo;s tools, do not maintain two files.</strong> Symlinks are a transition, not the destination.</p>
<h2 id="a-few-traps-i-have-hit">A few traps I have hit</h2>
<p><strong>① Do not turn AGENTS.md into a translated README.</strong>
A README is a project intro for humans. AGENTS.md is an operating manual for the agent. The difference: <strong>a README says what, AGENTS.md says how, and it has to be executable.</strong></p>
<p><strong>② Do not write should, recommend, try to.</strong>
How an agent handles vague wording is unpredictable. Either it is a hard rule (with the consequence stated) or leave it out.</p>
<p><strong>③ Do not let it go stale.</strong>
<strong>A stale command is worse than no command</strong> — the agent runs it and gets stuck on a task that no longer exists. When you change a build script, update AGENTS.md in the same pass. Make it a habit.</p>
<p><strong>④ Do not overstuff it.</strong>
The official minimal example is a dozen lines and it is enough. Context is a budget, and <strong>every line you write for the agent takes space away from understanding the code.</strong></p>
<p><strong>⑤ Draw the directory tree.</strong>
Best return on effort of anything here. <strong>One accurate tree saves the agent ten <code>ls</code> calls.</strong></p>
<h2 id="what-i-think">What I think</h2>
<p><strong>First, the AGENTS.md outcome is really platform-neutral beating tool-locked.</strong></p>
<p>Anthropic held out for nearly a year and then supported it anyway. The reason is simple: <strong>when 60,000 projects use one format, not supporting it means taxing your users.</strong></p>
<p>That complexity tax line from Shopify hits the real pain every large repo has.</p>
<p><strong>Second, for KMP projects AGENTS.md is probably worth more than for an ordinary project.</strong></p>
<p>Because KMP&rsquo;s traps are <strong>unusually directory-specific</strong>:</p>
<ul>
<li>Does this code belong in <code>commonMain</code> or <code>iosMain</code>? Completely different rules</li>
<li>Does this API exist on every platform?</li>
<li>Do I need to relink the iOS framework after this change?</li>
</ul>
<p><strong>Saying all of that out loud in every conversation is expensive. Written into directory-level AGENTS.md files, it becomes a one-time investment.</strong></p>
<p><strong>Third, the long-term significance may be bigger than it looks.</strong></p>
<p>As AI coding tools move from completing code to finishing tasks on their own, <strong>does the repo have a file that states the rules</strong> starts to look a lot like does this project have automated tests — <strong>a marker of engineering maturity.</strong></p>
<p>There is a side benefit too: <strong>writing AGENTS.md is itself an exercise in cleaning up team conventions.</strong> It forces you to write down the implicit rules that only old-timers know — <strong>and that may be worth more than making the AI a little smarter.</strong></p>
<hr>
<p>I would like to ask you:</p>
<p><strong>Does your project have an AGENTS.md?</strong> If not, which part do you think is hardest to write: the build commands, or the conventions that live only in people&rsquo;s heads?</p>
<p><strong>Or what trap have you hit in a KMP project that documentation later fixed?</strong> Tell me in the comments and I can turn it into the next post.</p>
<hr>
<p><em>Sources: the AGENTS.md spec (agents.md), The Register (9/18), 36Kr / CSDN coverage of the Shopify–Anthropic dispute (8/26), the Claude Code 2.1.277 release notes.</em></p>
]]></content:encoded></item><item><title>Kotlin 2.4 Is Here and HarmonyOS Is Adapting to 2.4: Two Fronts for KMP</title><link>http://mings.in/en/posts/kmp-24-ohos/</link><pubDate>Fri, 18 Sep 2026 12:34:38 +0800</pubDate><guid>http://mings.in/en/posts/kmp-24-ohos/</guid><description>A while back I wrote a piece on HarmonyOS and KMP, and at the time I found one key fact: the stable release the community published was HarmonyOS support built on KMP 2.2.21 / CMP 1.9.2.</description><content:encoded><![CDATA[<h2 id="one-branch-name-gives-away-what-is-happening">One branch name gives away what is happening</h2>
<p>A while back I wrote a piece on HarmonyOS and KMP, and at the time I found one key fact: the stable release the community published was HarmonyOS support built on <strong>KMP 2.2.21 / CMP 1.9.2</strong>.</p>
<p>Going back to that repo the other day, I found one extra line in the branch list:</p>
<blockquote>
<p><strong>develop-2.4</strong>
Documentation set for framework KMP <strong>2.4.21</strong> _ CMP <strong>1.12.0</strong></p>
</blockquote>
<p>And the branch <strong>was updated 21 hours ago</strong>.</p>
<p>There is another one:</p>
<blockquote>
<p><strong>main-2.2</strong> (default branch)
Documentation set for framework KMP <strong>2.2.21</strong> _ CMP <strong>1.9.2</strong></p>
</blockquote>
<p>On one side, the stable release already shipped. On the other, the new baseline being pushed forward.</p>
<p><strong>On the HarmonyOS front, KMP is starting to line up with the latest upstream version.</strong></p>
<p>And at almost the same moment, upstream JetBrains had just released Kotlin 2.4.20.</p>
<p>Two tracks, colliding at one point in time.</p>
<h2 id="first-the-upstream-track-what-changed-in-kotlin-2420">First, the upstream track: what changed in Kotlin 2.4.20</h2>
<p>If you only skim the changelog you might think, oh, another patch-and-polish release — of the two hundred-odd changes, most are bugfixes.</p>
<p>But a few of them land hard for <strong>people who write KMP</strong>.</p>
<p><strong>One: Swift interop can finally hold its own.</strong></p>
<p>Swift export now supports mapping Kotlin&rsquo;s <code>sealed class</code> straight to a Swift <code>enum</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="c1">// no more default</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">name</span> <span class="p">=</span> <span class="k">switch</span> <span class="n">shape</span><span class="p">.</span><span class="n">sealedType</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="p">.</span><span class="n">circle</span><span class="p">(</span><span class="kd">let</span> <span class="nv">t</span><span class="p">):</span>    <span class="s">&#34;圆&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="p">.</span><span class="n">rectangle</span><span class="p">(</span><span class="kd">let</span> <span class="nv">t</span><span class="p">):</span> <span class="s">&#34;方&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>   <span class="c1">// ← exhaustiveness check</span>
</span></span></code></pre></div><p>Handling this kind of polymorphic type used to mean either a pile of <code>if let</code> on the Swift side, or being forced to add a <code>default</code> branch that can never run — <strong>which kills exhaustiveness checking outright</strong>. Now the compiler catches the gaps for you.</p>
<p>There is also <strong>cross-language inheritance</strong>: a Kotlin type can inherit from a Swift class or protocol (including pure Swift libraries), with no wrapper layer needed.</p>
<p>Both point the same direction: <strong>the interop friction KMP has always been criticized for on iOS is being sanded down, bit by bit.</strong></p>
<p><strong>Two: klib incremental compilation hits Beta.</strong></p>
<pre tabindex="0"><code>kotlin.incremental.native=true
</code></pre><p>Change one line of Kotlin and you no longer wait for the whole native module to rebuild. If you have done Kotlin/Native, you know what that does for the day-to-day.</p>
<p><strong>Three: the Kotlin/JS test toolchain moved from Karma to Playwright</strong> — about time.</p>
<p><strong>So the theme of the upstream track is: smoothing out the seams between languages.</strong></p>
<h2 id="now-the-harmonyos-track-where-it-actually-stands">Now the HarmonyOS track: where it actually stands</h2>
<p>To understand the HarmonyOS side you have to start with its <strong>version numbering scheme</strong>, written in the stable release announcement that went out yesterday:</p>
<blockquote>
<p>Version numbers use &ldquo;<strong>upstream baseline + HarmonyOS adaptation sequence</strong>&rdquo;: 2.2.21-1.0.0 / 1.9.2-1.0.0 means a HarmonyOS adaptation stable release based on upstream KMP 2.2.21 and CMP 1.9.2.</p>
</blockquote>
<p>That rule matters. It says one thing:</p>
<blockquote>
<p><strong>HarmonyOS support is not a separate stove. It is chasing upstream versions.</strong></p>
</blockquote>
<p>Which is why you see numbers like <code>2.2.21-1.0.0</code> — the <code>2.2.21</code> in front is the upstream Kotlin version it corresponds to, and the <code>1.0.0</code> behind is the HarmonyOS adaptation&rsquo;s own iteration number.</p>
<p>Already published to Maven:</p>
<table>
	<thead>
			<tr>
					<th>Component</th>
					<th>Version</th>
					<th>Status</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>kotlin</td>
					<td>2.2.21-1.0.0</td>
					<td>Published to Maven</td>
			</tr>
			<tr>
					<td>compose-multiplatform</td>
					<td>1.9.2-1.0.0</td>
					<td>Published to Maven</td>
			</tr>
			<tr>
					<td>compose-multiplatform-core</td>
					<td>1.9.2-1.0.0</td>
					<td>Published to Maven</td>
			</tr>
			<tr>
					<td>skiko</td>
					<td>0.9.22.2-1.0.0</td>
					<td>Published to Maven</td>
			</tr>
			<tr>
					<td>akinterop</td>
					<td>1.0.1</td>
					<td>Published to Maven</td>
			</tr>
			<tr>
					<td>KMP OHOS Support</td>
					<td>1.0.0</td>
					<td>Published to the plugin marketplace</td>
			</tr>
	</tbody>
</table>
<p>And the new <code>develop-2.4</code> branch targets a baseline of <strong>KMP 2.4.21 / CMP 1.12.0</strong>.</p>
<p>The development cadence announcement says it too:</p>
<blockquote>
<p><strong>develop</strong> branch: used for monthly development-state code merges, <strong>merged into main after each monthly release gate</strong>.</p>
</blockquote>
<p>In plain language: <strong>one version forward every month, merged into the stable line only once it is ready.</strong> Not shipping on a whim, but a rolling cadence.</p>
<h2 id="what-that-branch-has-been-busy-with">What that branch has been busy with</h2>
<p>I went through the <code>develop-2.4</code> commit history. The recent activity is interesting.</p>
<p>The latest one adapts the <strong>CAPI user manual to API 26</strong> — bumping the OHOS CAPI version to API26, updating &ldquo;modules, minimum API, and typical dependency libraries&rdquo; to match, and adding a whole section on <strong>&ldquo;special changes caused by the CAPI upgrade to API 26.&rdquo;</strong></p>
<p>Another one updates <code>akinterop</code> to 1.0.1 and <code>sqldelight</code> to 2.2.1-1.1.0.</p>
<p><strong>Those two directions map exactly to the two real blockers for HarmonyOS cross-platform work:</strong></p>
<ul>
<li><strong>CAPI follows the HarmonyOS system version</strong> — the system upgrades, the bridge layer has to upgrade with it. That is the &ldquo;do not fall behind&rdquo; problem</li>
<li><strong>Adapted version numbers for third-party libraries</strong> — whether the ecosystem libraries keep up decides whether a project dares to use this at all</li>
</ul>
<h2 id="while-we-are-here-what-cmp-112-brings-upstream">While we are here: what CMP 1.12 brings upstream</h2>
<p>Since <code>develop-2.4</code> is tracking CMP 1.12.0, what changed in 1.12 upstream is worth a look too.</p>
<p>CMP 1.12.0 shipped on August 25, and the highlight is on the Desktop side:</p>
<blockquote>
<p>MCP server was introduced for Compose Hot Reload that enables <strong>AI agents to interact with a running Compose application in real time</strong></p>
</blockquote>
<p>In plain language: <strong>Compose hot reload got an MCP server, so an AI agent can interact with a running Compose app in real time.</strong></p>
<p>That one is subtle. The intersection of Compose and AI is moving from &ldquo;use AI to write Compose code&rdquo; toward &ldquo;<strong>let AI operate the running app directly</strong>.&rdquo;</p>
<p>It also brings a batch of breaking changes, so watch out when migrating:</p>
<ul>
<li>The deprecation level on typealiases like <code>NativeCanvas</code> and <code>NativePaint</code> was raised to <strong>ERROR</strong></li>
<li>The <code>background</code> parameter on <code>SwingPanel</code> is deprecated</li>
</ul>
<p><strong>Jumping from 1.9.2 to 1.12.0 is not just a version number change — migration cost counts too.</strong></p>
<h2 id="what-the-two-things-together-tell-you">What the two things together tell you</h2>
<p>Put the two tracks side by side and the picture is fairly clear:</p>
<p><strong>KMP&rsquo;s &ldquo;one codebase, many platforms&rdquo; is being pushed forward on several fronts at once.</strong></p>
<ul>
<li><strong>Upstream</strong> is polishing cross-language interop (Swift export, incremental compilation)</li>
<li><strong>The HarmonyOS community track</strong> is chasing upstream versions (2.2 → 2.4), filling in system support (CAPI → API 26), and widening the third-party library ecosystem</li>
</ul>
<p>They are not the same team, but they are heading in <strong>the same direction</strong> — making &ldquo;write Kotlin once, run on more platforms&rdquo; a little less friction.</p>
<p>For developers, the test is simple:</p>
<blockquote>
<p><strong>When you evaluate a new platform, the question is no longer &ldquo;does it support this&rdquo; but &ldquo;which upstream version has it caught up to, are the libraries enough, does the performance hold up.&rdquo;</strong></p>
</blockquote>
<p><strong>From &ldquo;can it run&rdquo; to &ldquo;can it keep up&rdquo; — that shift is itself a new stage.</strong></p>
<h2 id="but-the-communitys-real-voice-is-worth-more-than-the-announcement">But the community&rsquo;s real voice is worth more than the announcement</h2>
<p>Announcements are always pretty. I went and read the community discussion forum on purpose, because that is where this track&rsquo;s actual temperature is.</p>
<p><strong>The hottest thread asks &ldquo;can this be merged into the official JetBrains branch soon&rdquo;</strong> (started 30 days ago, 7 replies).</p>
<p>The poster was direct:</p>
<blockquote>
<p>Adding HarmonyOS to my existing pure KMP &amp; CMP app project <strong>runs into a large number of unsupported libraries</strong>, and during compilation several already-adapted libraries show internal dependency conflicts. I hope it can be merged upstream soon so library developers across the whole KMP community can add ohos versions, <strong>the CPF community&rsquo;s progress on library support is far too slow</strong>.</p>
</blockquote>
<p>He named three libraries specifically: <code>androidx-datastore</code>, <code>androidx-room3</code>, <code>androidx-sqlite</code> — &ldquo;these three are basically the foundational dependency libraries for the persistence approaches everyone uses.&rdquo;</p>
<p>Then the part that stings:</p>
<blockquote>
<p>None of them support ohos <strong>x86</strong>. On Windows a developer can barely build and test an app with normal functionality — at minimum you have to keep a HarmonyOS phone plugged in as the test target&hellip;</p>
</blockquote>
<p>He also gave a concrete trap: a <code>room3</code> alpha pulled from some mirror repo generates code that errors on a missing <code>androidx.sqlite</code> implementation, while upstream <code>room3 3.0.1</code> works fine.</p>
<p><strong>The official reply came, and it was honest:</strong></p>
<blockquote>
<p>Hello, <strong>support for ohos x86 is already in planning</strong>, thanks for your interest.</p>
</blockquote>
<p>Other threads are each real in their own way:</p>
<ul>
<li><strong>&ldquo;Under skia rendering the page frame production is dragged to about one frame per 3 seconds, suspected abnormality in the VsyncOnDemand deferred deregistration mechanism&rdquo;</strong> — performance</li>
<li><strong>&ldquo;material3 blocks porting an existing project&rdquo;</strong> — component library traps</li>
<li><strong>&ldquo;Is webView supported right now?&rdquo;</strong> — capability coverage</li>
<li><strong>&ldquo;Has anyone actually shipped a project? What traps did you hit&rdquo;</strong> — and someone replied with: <strong>&ldquo;currently doing a KMP adaptation of a voice assistant&rdquo;</strong></li>
</ul>
<p><strong>Add these up and you have the real state of this track: it runs, people are using it, but the ecosystem and performance are still catching up.</strong></p>
<h2 id="why-following-upstream-is-slow-work">Why &ldquo;following upstream&rdquo; is slow work</h2>
<p>Seeing that &ldquo;progress is far too slow&rdquo; line, some people will assume it is a matter of insufficient investment.</p>
<p>But there is a technical reality worth spelling out: <strong>HarmonyOS support changes the compiler backend. It is not writing a plugin.</strong></p>
<p>Go through the CPF-KMP-CMP repo list and you see these names:</p>
<p><code>kotlin</code>, <code>compose-multiplatform</code>, <code>compose-multiplatform-core</code>, <code>skiko</code>, <code>akinterop</code>, <code>cicd-toolkit</code>…</p>
<p><strong>These are HarmonyOS forks of the Kotlin compiler, the Compose framework, and the graphics rendering layer.</strong></p>
<p>Which means the work includes at least:</p>
<ul>
<li>Adding a new <strong>compilation target</strong> to Kotlin/Native</li>
<li>Wiring Compose&rsquo;s rendering pipeline into HarmonyOS&rsquo;s <strong>Render Service</strong></li>
<li>Getting the Skia graphics library that Skiko wraps to run on HarmonyOS</li>
</ul>
<p><strong>And every time upstream ships a new version, all of those changes have to be re-aligned.</strong></p>
<p>That is what the &ldquo;upstream baseline + adaptation sequence&rdquo; version scheme really means — <strong>it describes the nature of the work precisely: continuous tracking, not a one-time port.</strong></p>
<p>Look again at &ldquo;monthly development-state merges, merged into main after each monthly release gate&rdquo; and it reads differently:</p>
<p><strong>This is not slow. This is doing a very heavy thing at a pace that can be sustained.</strong></p>
<h2 id="a-few-of-my-own-views">A few of my own views</h2>
<p><strong>One: the essence of &ldquo;adaptation&rdquo; here is following upstream, not reinventing the wheel.</strong></p>
<p>You can see it in the version scheme alone (upstream baseline + adaptation sequence). The core work on the HarmonyOS track is getting upstream&rsquo;s compiler and UI framework running on HarmonyOS, not inventing a separate API.</p>
<p><strong>Which means its ceiling is tied to upstream</strong>: upstream smooths out Swift interop and that is a dividend for iOS; upstream improves the compiler and HarmonyOS only gets it by keeping up.</p>
<p><strong>Two: the three community complaints point at the same problem.</strong></p>
<p>x86 support, third-party library versions, performance — three things on the surface, all <strong>&ldquo;ecosystem maturity&rdquo;</strong> underneath.</p>
<p>And ecosystem maturity has a cruel property: <strong>it cannot be solved with enthusiasm, only with time and real projects wearing it in.</strong> The official reply of &ldquo;already in planning&rdquo; is true, and there is a certain helplessness in how true it is.</p>
<p><strong>Three: if you are evaluating whether to get on this track, here is a framework.</strong></p>
<p>Do not ask &ldquo;does it support HarmonyOS&rdquo; — <strong>ask these three questions</strong>:</p>
<ol>
<li><strong>Which upstream version has it caught up to?</strong> (right now 2.2.21 is stable, 2.4.21 is in development)</li>
<li><strong>Have the libraries I depend on been adapted?</strong> (especially foundational ones like persistence and networking — <code>room3</code> and <code>datastore</code> are the acknowledged pain points)</li>
<li><strong>What is my dev machine?</strong> (if you need an x86 emulator, you still have to wait; an Apple silicon Mac plus a real device is the steadier combination)</li>
</ol>
<p><strong>All three clear, then start. One blocked, and the plan goes in a note for later.</strong></p>
<h2 id="last">Last</h2>
<p>Kotlin 2.4 and HarmonyOS 2.4 look like two unrelated things.</p>
<p>But they are answering the same question:</p>
<blockquote>
<p><strong>One body of Kotlin code: how many places can it run, and how well?</strong></p>
</blockquote>
<p>Upstream is sanding down the seams between languages, and the HarmonyOS track is widening the map.</p>
<p><strong>Both are making that number bigger.</strong></p>
<hr>
<p>I want to ask you something:</p>
<p><strong>Would &ldquo;HarmonyOS support has caught up to 2.4&rdquo; make you reconsider a KMP project you have on your desk?</strong></p>
<p><strong>Or have you already been through the traps on this track?</strong> Talk in the comments — I would genuinely like to hear from real projects.</p>
<hr>
<p><em>References: CPF-KMP-CMP community repos (AtomGit/GitCode) branch and release announcements, public posts in the community discussion forum, JetBrains Kotlin 2.4.20 release notes.</em></p>
]]></content:encoded></item><item><title>Kotlin 2.4.20 Is Out: KMP's iOS Interop Can Finally Hold Its Own</title><link>http://mings.in/en/posts/kotlin-2420/</link><pubDate>Fri, 18 Sep 2026 07:02:39 +0800</pubDate><guid>http://mings.in/en/posts/kotlin-2420/</guid><description>On September 7, JetBrains released Kotlin 2.4.20.</description><content:encoded><![CDATA[<h2 id="swift-interop-from-it-runs-to-it-is-good-to-use">Swift interop: from &ldquo;it runs&rdquo; to &ldquo;it is good to use&rdquo;</h2>
<p>On September 7, JetBrains released Kotlin 2.4.20.</p>
<p>If you only skim the changelog you might think, oh, another patch-and-polish release — after all, of the two hundred-odd changes, most are bugfixes.</p>
<p>But for people writing KMP, a few things in this version are genuinely nice. Swift interop especially finally closes several long-standing gaps.</p>
<p>Conclusion first: <strong>the three things worth watching are Swift interop, compile speed, and cross-platform consistency</strong>. Taking them one at a time.</p>
<hr>
<h2 id="1-swift-export-supports-sealed-class-ios-folks-can-finally-skip-default-in-their-switch">1. Swift export supports sealed class: iOS folks can finally skip default in their switch</h2>
<p>If you have written iOS with KMP, you have almost certainly hit this awkward spot.</p>
<p>You define a perfectly elegant sealed interface in Kotlin:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">sealed</span> <span class="k">interface</span> <span class="nc">Shape</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Circle</span> <span class="p">:</span> <span class="n">Shape</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Rectangle</span> <span class="p">:</span> <span class="n">Shape</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">createCircle</span><span class="p">():</span> <span class="n">Shape</span> <span class="p">=</span> <span class="n">Circle</span><span class="p">()</span>
</span></span></code></pre></div><p>Logically seamless. On the Kotlin side you pattern match in one shot.</p>
<p>But on the Swift side, a Kotlin sealed type could only be treated as an ordinary class. You could not do exhaustive matching at all — just a pile of <code>if</code> checks and a <code>default</code> at the end to catch the rest.</p>
<p>The worse part: <strong>the day someone adds a new subclass on the Kotlin side, the Swift side does not fail to compile</strong> — so a case nobody handles can reach production.</p>
<p>2.4.20 fixes this. Sealed types now map to Swift enums, and the compiler generates a <code>sealedType()</code> method:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">shape</span> <span class="p">=</span> <span class="n">createCircle</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">name</span> <span class="p">=</span> <span class="k">switch</span> <span class="n">shape</span><span class="p">.</span><span class="n">sealedType</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="kd">let</span> <span class="p">.</span><span class="n">circle</span><span class="p">(</span><span class="n">type</span><span class="p">):</span> <span class="s">&#34;It&#39;s a </span><span class="si">\(</span><span class="n">type</span><span class="p">.</span><span class="n">value</span><span class="si">)</span><span class="s">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="kd">let</span> <span class="p">.</span><span class="n">rectangle</span><span class="p">(</span><span class="n">type</span><span class="p">):</span> <span class="s">&#34;It&#39;s a </span><span class="si">\(</span><span class="n">type</span><span class="p">.</span><span class="n">value</span><span class="si">)</span><span class="s">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Note this — <strong>no default</strong>.</p>
<p>The Swift compiler checks exhaustiveness, and because it maps to an enum, Xcode autocomplete works properly too. The day a new sealed subclass appears on the Kotlin side, the Swift side fails to compile and tells you to handle it.</p>
<p>That is a lot better than &ldquo;finding out at runtime that a case is missing.&rdquo;</p>
<h2 id="2-cross-language-inheritance-use-pure-swift-libraries-without-the-detour">2. Cross-language inheritance: use pure Swift libraries without the detour</h2>
<p>The second change is more practical: Swift export now supports cross-language inheritance.</p>
<p>What does that mean? A real scenario — you want to use CryptoKit, a pure Swift library, on the iOS side, but it cannot be imported into Kotlin directly.</p>
<p>The old approach was either an Objective-C bridging layer, or giving up and finding a substitute implementation on the Kotlin side. Now you can just do this:</p>
<p>Declare an interface and an open base class on the Kotlin side:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">interface</span> <span class="nc">CryptoProvider</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">fun</span> <span class="nf">hashMD5</span><span class="p">(</span><span class="n">input</span><span class="p">:</span> <span class="n">String</span><span class="p">):</span> <span class="n">String</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">processHash</span><span class="p">(</span><span class="n">provider</span><span class="p">:</span> <span class="n">CryptoProvider</span><span class="p">,</span> <span class="n">input</span><span class="p">:</span> <span class="n">String</span><span class="p">):</span> <span class="n">String</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">    <span class="n">provider</span><span class="p">.</span><span class="n">hashMD5</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">open</span> <span class="k">class</span> <span class="nc">SwiftBase</span>
</span></span></code></pre></div><p>Inherit the base class and implement the interface on the Swift side, then pass the object back to Kotlin:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">CryptoKit</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">final</span> <span class="kd">class</span> <span class="nc">IosCryptoProvider</span><span class="p">:</span> <span class="n">SwiftBase</span><span class="p">,</span> <span class="n">CryptoProvider</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">hashMD5</span><span class="p">(</span><span class="n">input</span><span class="p">:</span> <span class="nb">String</span><span class="p">)</span> <span class="p">-&gt;</span> <span class="nb">String</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">guard</span> <span class="kd">let</span> <span class="nv">data</span> <span class="p">=</span> <span class="n">input</span><span class="p">.</span><span class="n">data</span><span class="p">(</span><span class="n">using</span><span class="p">:</span> <span class="p">.</span><span class="n">utf8</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="s">&#34;failed&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Insecure</span><span class="p">.</span><span class="n">MD5</span><span class="p">.</span><span class="n">hash</span><span class="p">(</span><span class="n">data</span><span class="p">:</span> <span class="n">data</span><span class="p">).</span><span class="n">description</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">provider</span> <span class="p">=</span> <span class="n">IosCryptoProvider</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="bp">print</span><span class="p">(</span><span class="n">processHash</span><span class="p">(</span><span class="n">provider</span><span class="p">:</span> <span class="n">provider</span><span class="p">,</span> <span class="n">input</span><span class="p">:</span> <span class="s">&#34;Hello, world!&#34;</span><span class="p">))</span>
</span></span></code></pre></div><p>When Kotlin receives that Swift object it calls it as a normal implementation of the interface.</p>
<p>This is what the team calls the reverse import pattern: <strong>the contract lives in Kotlin, the implementation lives in Swift</strong>. For cases where you must depend on a platform-native library, the road is finally clear.</p>
<h2 id="3-incremental-compilation-hits-beta-change-one-line-stop-waiting-for-a-coffee">3. Incremental compilation hits Beta: change one line, stop waiting for a coffee</h2>
<p>This one may be the biggest improvement to day-to-day development.</p>
<p>Kotlin/Native compile times have always been one of KMP&rsquo;s sore spots. klib incremental compilation has existed since 1.9.20, but it kept getting polished. Now it enters Beta officially, and the team says debug build times drop substantially.</p>
<p>Turning it on is one line in <code>gradle.properties</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">kotlin.incremental.native</span><span class="o">=</span><span class="s">true</span>
</span></span></code></pre></div><p>The team also mentions they plan to enable it by default in an upcoming version.</p>
<p>While we are here: when exporting an XCFramework, the <code>assembleSharedXCFramework</code> task now generates a <code>Package.swift</code> file automatically. Publishing an XCFramework with SwiftPM dependencies used to mean handling SwiftPM distribution yourself. That is now taken care of.</p>
<hr>
<h2 id="4-wasm-a-few-changes-that-are-easy-to-miss-and-hard-to-skip">4. Wasm: a few changes that are easy to miss and hard to skip</h2>
<p>If your project has a web side, these are worth noting.</p>
<p><strong>Companion object initialization order now matches the JVM</strong></p>
<p>In Kotlin/Wasm, companion object initialization order for parent and child classes could run opposite to the JVM. In cross-platform projects, that kind of difference turns into the worst bugs: works fine locally, behaves like a ghost on another platform. It now follows JVM behavior.</p>
<p><strong>Wasmtime runtime support</strong></p>
<p>The <code>wasmWasi</code> target used to run only through Node.js, and needed a JavaScript bootstrap layer. Now you can use Wasmtime, the standalone WebAssembly runtime:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">wasmWasi</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">wasmtime</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><strong>New compilation modes</strong></p>
<p>Previously there was only monolith mode (compile the whole project and its dependencies together into a single binary — smallest output, but a full rebuild every time). Now there are two multi-module modes, so you can pick:</p>
<ul>
<li>Use <code>multimodule-closed-world</code> during development to recompile only changed modules, for faster iteration</li>
<li>Keep monolith for production builds, to keep the output as small as possible</li>
</ul>
<p>Configuration:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">kotlin.wasm.compilationMode</span><span class="o">=</span><span class="s">multimodule-open-world</span>
</span></span></code></pre></div><p><strong>Watch for one breaking change</strong>: top-level <code>require()</code> inside a <code>@JsFun</code> declaration now fails to compile. If you wrote it that way before, migrate to the <code>@JsModule</code> annotation or an <code>import()</code> expression.</p>
<h2 id="5-kotlinjs-karma-retires-playwright-takes-over">5. Kotlin/JS: Karma retires, Playwright takes over</h2>
<p>Kotlin/JS browser tests have always used Karma as the launcher, but the Karma project has gone unmaintained for two years.</p>
<p>2.4.20 introduces a new test DSL: Playwright underneath as the driver (supporting Chromium, Firefox, and WebKit), Mocha as the test runner, and webpack still as the bundler for now (the team says Vite comes later).</p>
<p>Usage is a <code>test {}</code> block inside <code>browser {}</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">kotlin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">js</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">browser</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nd">@OptIn</span><span class="p">(</span><span class="n">ExperimentalJsTestDsl</span><span class="o">::</span><span class="k">class</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">timeout</span> <span class="p">=</span> <span class="mi">2</span><span class="p">.</span><span class="n">seconds</span>
</span></span><span class="line"><span class="cl">                <span class="n">chromium</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">                <span class="n">firefox</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">                <span class="n">webkit</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Also, suspend lambdas can now be exported as JavaScript async functions — handy for cross-platform calls.</p>
<h2 id="6-standard-library-and-everything-else">6. Standard library and everything else</h2>
<p>A few handy ones:</p>
<ul>
<li><strong>Coroutine stack trace recovery</strong>: a new <code>StackTraceRecoverable</code> interface. When exceptions fly between coroutines, you can see where they came from and who rethrew them. For exceptions with complex constructor parameters, implement <code>copyForStackTraceRecovery()</code>.</li>
<li><strong>Collection utilities</strong>: new functions for checking element equality and uniqueness.</li>
<li><strong>kotlin.test</strong>: new assertion function overloads.</li>
<li><strong>Gradle</strong>: full compatibility from Gradle 7.6.3 through 9.7.0; the Problems API now carries compiler diagnostic IDs and groups them, so you can locate the source of a problem fast when debugging.</li>
<li><strong>Compiler</strong>: invokedynamic compilation of <code>when</code> expressions on JVM 21+ is now stable (on by default); a new <code>kotlinr</code> run command and a standalone native image.</li>
</ul>
<hr>
<h2 id="what-these-changes-mean-for-kmp-developers">What these changes mean for KMP developers</h2>
<p>Put it all together and I think 2.4.20 sends three signals.</p>
<p><strong>1. iOS interop is moving from &ldquo;works&rdquo; to &ldquo;works well&rdquo;</strong></p>
<p>Sealed classes mapping to enums, cross-language inheritance for pure Swift libraries — both hit exactly what people complain about most on the iOS side of KMP. People used to say KMP is &ldquo;good on Android, a bit lacking on iOS.&rdquo; That gap is visibly narrowing.</p>
<p><strong>2. Compile experience is being treated as a real problem</strong></p>
<p>klib incremental compilation into Beta, Wasm multi-module compilation — both solve the same thing: <strong>not making daily development wait</strong>. Compile speed is one of KMP&rsquo;s most concrete reasons people bounce off it. This is the right direction.</p>
<p><strong>3. Cross-platform consistency is getting more attention</strong></p>
<p>Wasm companion object initialization matching the JVM looks like a small change, but &ldquo;behavior differs across platforms&rdquo; is exactly the nastiest class of bug in cross-platform projects. The team is actively narrowing those differences.</p>
<p>One other piece of news while we are here: Google released <strong>ADK for Kotlin 1.0</strong> a few days ago, for writing production-grade AI agents in Kotlin. The Kotlin and AI agent circles have visibly been overlapping a lot more this year.</p>
<hr>
<h2 id="upgrade-advice">Upgrade advice</h2>
<ul>
<li>Ordinary projects: just upgrade. This release is mostly fixes, low risk.</li>
<li><strong>KMP projects</strong>: strongly recommended. The Swift export changes are a real improvement in day-to-day feel.</li>
<li><strong>Projects using Wasm</strong>: check <code>require()</code> calls inside <code>@JsFun</code> before upgrading. That is the only breaking change.</li>
<li><strong>Want to try incremental compilation</strong>: add <code>kotlin.incremental.native=true</code> and test the waters; report problems upstream.</li>
</ul>
<p>Kotlin&rsquo;s release cadence has always been steady, no great leaps. This &ldquo;fix a few real problems each time&rdquo; style of iteration is, honestly, the friendliest thing for production projects.</p>
<hr>
<p><em>References: <a href="https://kotlinlang.org/docs/whatsnew2420.html">What&rsquo;s new in Kotlin 2.4.20</a> · <a href="https://github.com/JetBrains/kotlin/releases/tag/v2.4.20">GitHub Release</a></em></p>
]]></content:encoded></item><item><title>A Kotlin Legend Turned Down 80% of the Job Market: Why I Flatly Refuse to Use AI for Code</title><link>http://mings.in/en/posts/jake-wharton-ai/</link><pubDate>Thu, 17 Sep 2026 07:24:40 +0800</pubDate><guid>http://mings.in/en/posts/jake-wharton-ai/</guid><description>If you are a senior engineer, what do you write down as your requirements when you go job hunting?</description><content:encoded><![CDATA[<h2 id="a-job-hunting-criterion-that-makes-no-sense-at-first">A job-hunting criterion that makes no sense at first</h2>
<p>If you are a senior engineer, what do you write down as your requirements when you go job hunting?</p>
<p>More money, less work, a modern stack, a solid team. Roughly that.</p>
<p>Jake Wharton is different. This Android / Kotlin open source legend, known to Chinese developers as &ldquo;J God,&rdquo; drew up nine criteria for choosing a job.</p>
<p><strong>The first one: do not join an AI company, do not work on AI-centric products, and do not accept a company forcing AI on its engineers.</strong></p>
<p>By his own estimate, that single line rules out about 80% of potential employers.</p>
<p>Before you rush to call him precious about it: it is not that he has no options. A senior Kotlin engineer in the US typically starts at a $200K base salary, plus roughly $100K a year in equity vesting over four years. Going into AI could pay more.</p>
<p>He did it anyway.</p>
<p>InfoQ published the full transcript of this two-hour interview a few days ago, over thirteen thousand characters. What struck me most was not his conclusion. It was the reasoning he gave.</p>
<h2 id="who-he-is-one-of-the-people-who-made-kotlin-androids-official-language">Who he is: one of the people who made Kotlin Android&rsquo;s official language</h2>
<p>If you are not in the Kotlin world, you may not know how much weight that name carries.</p>
<p>Jake Wharton is the author or core contributor of OkHttp, Okio, Retrofit and other star open source libraries. Anyone who has written Android has probably used his code.</p>
<p>More importantly: <strong>he was the first engineer on the Google Kotlin team.</strong></p>
<p>Before Google, he worked at Square (later Cash App). To convince the company to adopt Kotlin internally, he wrote an argument document. He later made it public, and it indirectly drove the whole Android community&rsquo;s interest in Kotlin.</p>
<p>Then Google decided to make Kotlin an official first-class language for Android, and he joined that team.</p>
<p>He says the opportunity was &ldquo;almost impossible to turn down.&rdquo; Nothing is a bigger endorsement than joining the company responsible for the entire Android toolchain and pushing Kotlin to official status.</p>
<p><strong>So he is not anti-technology. He is someone who bet his career on a language and a toolchain.</strong></p>
<p>Which raises the question: why is a person like that so resistant to AI coding?</p>
<h2 id="reason-one-ai-spreads-in-the-opposite-direction-from-every-tool-before-it">Reason one: AI spreads in the opposite direction from every tool before it</h2>
<p>This is the passage I agreed with most. Wharton&rsquo;s words:</p>
<blockquote>
<p>Traditionally, a development tool is usually pushed bottom-up by engineers. Maybe one engineer falls in love with a new language, Kotlin for instance. Or a new IDE, IntelliJ IDEA. Or a new build system. The engineer finds value in it at work, the enthusiasm spreads to others, and then everyone goes and convinces engineering management to approve adopting it.</p>
<p>But AI tools seem to spread in the exact opposite direction. Why is a company CEO telling engineers what tools they should use in their daily work?</p>
</blockquote>
<p>That is a sharp observation.</p>
<p>Think about how Kotlin took off: <strong>a group of engineers genuinely found it good, started using it quietly in their projects, and slowly convinced their bosses.</strong> Bottom-up. Organic.</p>
<p>The rollout path for AI coding tools at plenty of companies looks like this: <strong>executives see the news, mandate that everyone must use AI to boost productivity, and put it in performance reviews.</strong> Top-down. Administrative.</p>
<p><strong>The legitimacy of a tool used to come from it actually being good. Now, sometimes, it comes from being mandated.</strong></p>
<p>That shift is worth a thought from every engineer.</p>
<h2 id="reason-two-what-you-hand-over-is-capability-not-just-work">Reason two: what you hand over is capability, not just work</h2>
<p>On the idea that AI turns engineers into cogs, here is his full formulation:</p>
<blockquote>
<p>AI can help engineers handle repetitive work, but the most irresponsible use is letting AI generate code beyond the user&rsquo;s own capability. If you cannot complete the task without a large model, you often cannot truly understand, verify or maintain the result either.</p>
<p>Once dependency forms, what an engineer hands over is not just part of the work but their own capability and bargaining power, and eventually they may become a cog that is easier to replace at low cost.</p>
</blockquote>
<p>Note the boundary of that statement: <strong>he did not say &ldquo;do not use AI.&rdquo; He said &ldquo;do not use AI to generate code beyond your capability.&rdquo;</strong></p>
<p>Those are very different sentences.</p>
<p>The first means &ldquo;AI will replace you.&rdquo; The second describes a more specific and much quieter risk:</p>
<p>If a requirement would normally take you two days to think through the design, and now AI spits out working code in ten seconds that you do not fully understand —</p>
<p><strong>you have the code, but your understanding of the system has actually degraded.</strong></p>
<p>A few rounds of that, and part of what you maintain is something you cannot read yourself.</p>
<p>At that point, where is your value anchored?</p>
<h2 id="reason-three-copyright-and-licenses-the-accounting-is-not-settled">Reason three: copyright and licenses, the accounting is not settled</h2>
<p>He is direct about this one:</p>
<blockquote>
<p>As I see it, they are built on large-scale use of copyrighted content. Model training ignored artists&rsquo; copyright and ignored open source licenses.</p>
<p>Being able to access something on the internet does not mean you have the right to recombine it, rewrite it, or output it verbatim.</p>
</blockquote>
<p>For someone who has poured a lot of himself into open source, the position is easy to understand. <strong>His code was taken to train models, and then what the models produce is sold back to him. Ethically, that accounting really is not settled.</strong></p>
<p>He adds a detail: open source maintainers now have one more burden to deal with — <strong>a flood of low-quality AI-generated PRs.</strong></p>
<p>I believe that. Anyone who has maintained an open source project has felt it.</p>
<h2 id="but-he-does-not-think-ai-is-going-away">But he does not think AI is going away</h2>
<p>Do not misread him. His read on AI&rsquo;s capabilities in the interview is actually restrained and clear-eyed:</p>
<blockquote>
<p>&ldquo;AI is already unnervingly powerful,&rdquo; but that does not mean it can carry the whole software engineering process reliably and without error.</p>
<p>To him, AI can at most be one tool in the toolbox. It cannot become the foundation of engineering capability.</p>
</blockquote>
<p>&ldquo;Not the foundation.&rdquo; I think that metaphor is exactly right.</p>
<p><strong>A tool in the toolbox is something you can leave unused. A foundation is something you cannot stand without.</strong></p>
<p>He also makes a counterintuitive point: AI being able to write code does not mean open source libraries lose value. Because the value of open source is not just &ldquo;here is a piece of code that works.&rdquo; It is <strong>the long-term dividend of continuous maintenance</strong> —</p>
<blockquote>
<p>Someone may fix an edge-case bug today that you would not hit yet. Two years later, when you refactor your business, you would have triggered it, but because you use the same open source library, it was already solved ahead of time.</p>
</blockquote>
<p>The logic is tight: <strong>AI gives you one copy of code today. Open source gives you continuously evolving public infrastructure.</strong> Those are not the same thing.</p>
<h2 id="kotlin-talk-the-parts-that-matter-to-us">Kotlin talk, the parts that matter to us</h2>
<p>The second half of the interview is about Kotlin, and a few points are genuinely useful if you write Kotlin:</p>
<p><strong>1. &ldquo;Kotlin looks a lot like what Java wants to be in ten years&rdquo;</strong></p>
<p>His explanation: Java has to avoid breaking existing code, so the language evolves slowly. Kotlin can hand developers many modern features that might land in Java later, and still works with codebases stuck on Java 8 / 11.</p>
<p><strong>2. Knowing Kotlin is no longer a career advantage</strong></p>
<p>That stings, but his explanation is fair:</p>
<blockquote>
<p>I am not sure it counts as an advantage anymore. In a sense that reflects Kotlin&rsquo;s success: it has become a default requirement rather than a scarce, hard-to-find skill.</p>
</blockquote>
<p>Because Google made Kotlin the official Android language, people learning Android learn Kotlin directly, and employers assume you know it.</p>
<p><strong>But —</strong></p>
<blockquote>
<p>If a developer has more than routine Kotlin, things like Kotlin Multiplatform that are relatively advanced, that can still be a differentiator and set you apart from other Kotlin developers.</p>
</blockquote>
<p><strong>&ldquo;KMP is still a differentiator&rdquo;</strong> is probably the most direct signal in this interview for people like us.</p>
<p><strong>3. Meta donated its Kotlin formatter</strong></p>
<p>In the same week he recorded this episode, Meta contributed its Kotlin formatter to the Kotlin Foundation. It may become the official formatter.</p>
<p><strong>4. In 2026, Kotlin is governed by three parties</strong></p>
<p>The ideal is JetBrains, Google and the community all participating. One of the reasons the Kotlin Foundation exists is so the language is no longer wholly owned by JetBrains — so no single company controls it.</p>
<h2 id="my-own-bind-building-an-ai-client-while-listening-to-him-argue-against-ai">My own bind: building an AI client while listening to him argue against AI</h2>
<p>A personal note.</p>
<p>I am writing a booklet about <strong>AI chat clients</strong> right now — building a cross-platform AI chat app from scratch. The stack is KMP.</p>
<p>So reading this interview gave me a strange sense of dislocation: <strong>I am building a product on AI capabilities while one of the most respected people in the industry says AI is ruining engineering.</strong></p>
<p>Calm down and think about it, though, and I do not find it contradictory.</p>
<p><strong>What he opposes was never &ldquo;the tool called AI.&rdquo; It is three things:</strong></p>
<ol>
<li>Treating AI as mandatory organizational discipline rather than an optional tool</li>
<li>Using AI as a substitute for thinking, losing your ability to verify and maintain</li>
<li>Business models built on copyright and license problems</li>
</ol>
<p>Of those three, only the second is something I have to stay vigilant about — especially when I write code and write tutorials myself.</p>
<p><strong>Tools will keep getting stronger. That is the trend, you cannot stop it and you do not need to. But &ldquo;is my judgment still in my own hands&rdquo; is something only you can be responsible for.</strong></p>
<p>That is probably what he means by &ldquo;not the foundation.&rdquo;</p>
<h2 id="last">Last</h2>
<p>One more line from the interview stuck with me:</p>
<blockquote>
<p>I do not think the overall quality of software has improved over the past few years, and I do not think these tools will improve software quality in the long run. But some company executives see a different picture.</p>
</blockquote>
<p>Which picture is right? Nobody knows yet. It may take five years to find out.</p>
<p><strong>But one risk is certain: if a person outsources all of their capability, then whether or not AI is that strong, they have already lost their bargaining power.</strong></p>
<p>What do you make of it? How much of your current work involves AI?</p>
<p>Tell me in the comments — I especially want to know if anyone else is building products with AI while quietly uneasy about where its boundaries are.</p>
<hr>
<p><em>Sources: <a href="https://www.infoq.cn/article/QPRcUdPGic5r8Ws8R1gM">full Jake Wharton interview (InfoQ, 2026-09-11)</a> · <a href="https://www.youtube.com/watch?v=9WjPdgOK-68">original video</a></em></p>
]]></content:encoded></item><item><title>Kotlin Developers Can Finally Write Production-Grade AI Agents: A Deep Dive into Google ADK for Kotlin 1.0</title><link>http://mings.in/en/posts/adk-kotlin-10/</link><pubDate>Thu, 17 Sep 2026 07:24:40 +0800</pubDate><guid>http://mings.in/en/posts/adk-kotlin-10/</guid><description>Anyone who writes Kotlin has been carrying the same nagging feeling for the past two years.</description><content:encoded><![CDATA[<h2 id="what-an-agent-framework-with-a-kmp-core-actually-means">What an agent framework with a KMP core actually means</h2>
<p>Anyone who writes Kotlin has been carrying the same nagging feeling for the past two years.</p>
<p>The AI agent wave keeps rolling in, but open the source and look: LangChain, CrewAI, AutoGen, all Python. If you want to build an agent inside the Kotlin ecosystem you already know, you either hand-roll it on an HTTP client, or write a Python service and call it from Kotlin, with a process boundary and serialization sitting in between for good measure.</p>
<p>On September 9, Google filled that gap: <strong>Agent Development Kit (ADK) for Kotlin 1.0 is officially GA</strong>.</p>
<p>And this is not a straight port of the Python version. Underneath it is a <strong>Kotlin Multiplatform (KMP) core</strong>.</p>
<p>That detail matters. More on it later.</p>
<h2 id="first-what-this-thing-actually-is">First, what this thing actually is</h2>
<p>ADK for Kotlin&rsquo;s positioning is clear: <strong>fully decoupled from the model backend, session storage, and memory system</strong>.</p>
<p>In plain terms: you write the agent logic once in Kotlin, and the model can be Gemini or something else entirely; sessions can live in memory, in Room, or in the cloud, and you swap them as you like.</p>
<p>1.0 is feature-aligned with ADK Python and Java, and it brings over the multi-agent patterns that matured in the Python ecosystem:</p>
<ul>
<li><strong>Hierarchical multi-agent</strong>: agents chain together and delegate tasks to specialized sub-agents</li>
<li><strong>Context compaction</strong>: history that grows too long gets summarized automatically, keeping tokens inside the window</li>
<li><strong>Human-in-the-loop</strong>: sensitive operations pause and wait for user confirmation before continuing</li>
<li><strong>Resumable sessions</strong>: agent interaction state serializes and restores across sessions</li>
<li><strong>Java interop</strong>: existing Java applications can call Kotlin-written agents directly</li>
<li><strong>Vertex AI integration</strong>: enterprise Session, RAG Memory, and Memory Bank services</li>
</ul>
<p>One of these deserves its own paragraph: <strong>context compaction</strong>.</p>
<p>Anyone who has written an AI chat client knows that managing history across turns is unavoidable work. A sliding window loses information, and stuffing everything in blows the token budget. ADK makes it a built-in framework capability, so you do not have to build that wheel yourself.</p>
<h2 id="the-technical-part-why-this-counts-as-kotlin-native">The technical part: why this counts as Kotlin-native</h2>
<p>This is the part of ADK for Kotlin I find most interesting.</p>
<h3 id="tools-as-annotations-schemas-generated-at-compile-time">Tools as annotations, schemas generated at compile time</h3>
<p>Python agent frameworks generally lean on runtime reflection to read function signatures and generate tool descriptions. ADK for Kotlin takes another road: <strong>generation at compile time with KSP (Kotlin Symbol Processing)</strong>.</p>
<p>The code you write is just an ordinary Kotlin function:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">InfrastructureDiagnosticsService</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nd">@Tool</span>
</span></span><span class="line"><span class="cl">    <span class="k">suspend</span> <span class="k">fun</span> <span class="nf">getServiceMetrics</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Target service or database cluster&#34;</span><span class="p">)</span> <span class="n">serviceName</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Time window in minutes&#34;</span><span class="p">)</span> <span class="n">windowMinutes</span><span class="p">:</span> <span class="n">Int</span><span class="p">?</span> <span class="p">=</span> <span class="mi">15</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">):</span> <span class="n">ServiceMetrics</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// query the monitoring backend: Datadog, Prometheus, Cloud Monitoring
</span></span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ServiceMetrics</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">serviceName</span> <span class="p">=</span> <span class="n">serviceName</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">cpuUsagePercent</span> <span class="p">=</span> <span class="mf">91.4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">connectionPoolUsagePercent</span> <span class="p">=</span> <span class="mf">98.5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">p99LatencyMs</span> <span class="p">=</span> <span class="mi">2450</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">errorRatePercent</span> <span class="p">=</span> <span class="mf">4.2</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>KSP generates a <code>generatedTools()</code> extension function at compile time.</p>
<p><strong>Three things follow from that</strong>: type safety (the schema cannot drift away from the signature), suspend function support (coroutines fit in naturally), and zero runtime reflection (fast startup, small binary, which matters a lot on Android).</p>
<p>If you are used to compile-time checking in Kotlin, this design direction should resonate: <strong>anything the compiler can catch, do not defer to runtime</strong>.</p>
<h3 id="skills-progressive-disclosure">Skills: progressive disclosure</h3>
<p>ADK has a concept called a Skill, and I think it is cleverly designed.</p>
<p>You write a standard operating procedure as <code>SKILL.md</code> and drop it into resources:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">database-incident-triage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="l">Standard operating procedure for diagnosing database latency spikes.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">allowed-tools</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">getServiceMetrics, fetchRecentDeployments, notifyOnCall]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gh"># Database Incident Triage SOP
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">1.</span> <span class="gs">**Telemetry**</span>: Call <span class="sb">`getServiceMetrics`</span> to inspect CPU, latency, and pool saturation.
</span></span><span class="line"><span class="cl"><span class="k">2.</span> <span class="gs">**Correlation**</span>: Call <span class="sb">`fetchRecentDeployments`</span> to check for recent changes.
</span></span><span class="line"><span class="cl"><span class="k">3.</span> <span class="gs">**Safety Rules**</span>: Never restart primary nodes during peak hours.
</span></span><span class="line"><span class="cl"><span class="k">4.</span> <span class="gs">**Notify**</span>: Broadcast diagnosis to <span class="sb">`#production-alerts`</span>.
</span></span></code></pre></div><p>The agent only loads that SOP into context when it needs it, and bundled resource files such as <code>assets/mitigation_rules.txt</code> are pulled on demand too.</p>
<p>The team calls this <strong>progressive disclosure</strong>: <strong>spend tokens where they matter, do not dump every piece of knowledge into the model up front</strong>.</p>
<h2 id="hands-on-a-database-incident-triage-agent">Hands on: a database incident triage agent</h2>
<p>The official example is a practical one. Suppose production fires a database alert and we want an agent to help triage it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">object</span> <span class="nc">IncidentTriageDemoAgent</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">rootAgent</span> <span class="p">=</span> <span class="n">LlmAgent</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;incident_triage_agent&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">model</span> <span class="p">=</span> <span class="n">Gemini</span><span class="p">(</span><span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;gemini-3.8-flash&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">instruction</span> <span class="p">=</span> <span class="n">Instruction</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">            You are an SRE on-call diagnostic assistant.
</span></span></span><span class="line"><span class="cl"><span class="s">            1. Load the matching triage SOP using `load_skill`.
</span></span></span><span class="line"><span class="cl"><span class="s">            2. Follow the playbook steps strictly.
</span></span></span><span class="line"><span class="cl"><span class="s">            3. Use your diagnostics tools to inspect telemetry and notify the team.
</span></span></span><span class="line"><span class="cl"><span class="s">            &#34;&#34;&#34;</span><span class="p">.</span><span class="n">trimIndent</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// compile-time generated function tools (zero reflection)
</span></span></span><span class="line"><span class="cl">        <span class="n">tools</span> <span class="p">=</span> <span class="n">InfrastructureDiagnosticsService</span><span class="p">().</span><span class="n">generatedTools</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// dynamic skill toolset
</span></span></span><span class="line"><span class="cl">        <span class="n">toolsets</span> <span class="p">=</span> <span class="n">listOf</span><span class="p">(</span><span class="n">SkillToolset</span><span class="p">(</span><span class="n">NewFileSystemSource</span><span class="p">(</span><span class="n">resolveSkillsDir</span><span class="p">()))),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Run it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">main</span><span class="p">()</span> <span class="p">=</span> <span class="n">runBlocking</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">runner</span> <span class="p">=</span> <span class="n">InMemoryRunner</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">agent</span> <span class="p">=</span> <span class="nc">IncidentTriageDemoAgent</span><span class="p">.</span><span class="n">rootAgent</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">appName</span> <span class="p">=</span> <span class="s2">&#34;IncidentTriageApp&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">alert</span> <span class="p">=</span> <span class="s2">&#34;ALERT [P1]: Database latency spike detected on &#39;users-postgres-cluster&#39;!&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">val</span> <span class="py">events</span> <span class="p">=</span> <span class="n">runner</span><span class="p">.</span><span class="n">runAsync</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">userId</span> <span class="p">=</span> <span class="s2">&#34;oncall-sre&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">sessionId</span> <span class="p">=</span> <span class="nc">UUID</span><span class="p">.</span><span class="n">randomUUID</span><span class="p">().</span><span class="n">toString</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">        <span class="n">newMessage</span> <span class="p">=</span> <span class="nc">Content</span><span class="p">.</span><span class="n">fromText</span><span class="p">(</span><span class="nc">Role</span><span class="p">.</span><span class="n">USER</span><span class="p">,</span> <span class="n">alert</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">).</span><span class="n">toList</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="n">event</span> <span class="k">in</span> <span class="n">events</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">event</span><span class="p">.</span><span class="n">content</span><span class="o">?.</span><span class="n">parts</span><span class="o">?.</span><span class="n">firstOrNull</span><span class="p">()</span><span class="o">?.</span><span class="n">text</span><span class="o">?.</span><span class="n">let</span> <span class="p">{</span> <span class="n">println</span><span class="p">(</span><span class="s2">&#34;Agent: </span><span class="si">$it</span><span class="s2">&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Note the <code>runBlocking</code> plus streaming events. <strong>Coroutines are a first-class citizen here</strong>, not something bolted on afterwards.</p>
<p>On receiving the alert, the agent walks the whole flow by itself: load the SOP, call <code>getServiceMetrics()</code> and find the connection pool at 98.5%, call <code>fetchRecentDeployments()</code> and pin it to a deploy 25 minutes ago, post the diagnosis to the alert channel, recommend a rollback.</p>
<p>That is a complete loop of <strong>tool calls, domain knowledge, and autonomous decision-making</strong>.</p>
<h2 id="on-device-android-it-is-not-cloud-only">On-device Android: it is not cloud-only</h2>
<p>If you work on mobile, the Android extensions may be the more exciting part for you.</p>
<p>The hard part of mobile AI is the balance: cloud inference is capable, but latency, privacy, and offline availability are all problems. ADK for Kotlin 1.0 ships a modular Android story:</p>
<ul>
<li><strong>LiteRT-LM</strong>: small models on device</li>
<li><strong>ML Kit (beta)</strong>: local inference</li>
<li><strong>Firebase AI Logic</strong>: hybrid cloud and on-device orchestration</li>
<li><strong>Room persistence</strong>: history in SQLite, survives process death</li>
<li><strong>AppSearch indexed memory</strong>: an on-device full-text memory service</li>
<li><strong>FileArtifactService</strong>: generated files such as statements and receipts land directly in local storage</li>
</ul>
<p>The official financial assistant sample wires all of it together:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">createAndroidRunner</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">:</span> <span class="n">Context</span><span class="p">,</span> <span class="n">agent</span><span class="p">:</span> <span class="n">LlmAgent</span><span class="p">):</span> <span class="n">InMemoryRunner</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">InMemoryRunner</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">agent</span> <span class="p">=</span> <span class="n">agent</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">appName</span> <span class="p">=</span> <span class="s2">&#34;AndroidFinancialApp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// SQLite persistence: sessions survive process death
</span></span></span><span class="line"><span class="cl">        <span class="n">sessionService</span> <span class="p">=</span> <span class="nc">RoomSessionService</span><span class="p">.</span><span class="n">fromContext</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// on-device full-text indexed memory
</span></span></span><span class="line"><span class="cl">        <span class="n">memoryService</span> <span class="p">=</span> <span class="nc">AppSearchMemoryService</span><span class="p">.</span><span class="n">fromContext</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// app-private file storage
</span></span></span><span class="line"><span class="cl">        <span class="n">artifactService</span> <span class="p">=</span> <span class="nc">FileArtifactService</span><span class="p">.</span><span class="n">fromExternalFilesDir</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>And HITL is handled pragmatically. For something sensitive like a transfer, you just add a flag when defining the tool:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nd">@Tool</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">name</span> <span class="p">=</span> <span class="s2">&#34;transferFunds&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="p">=</span> <span class="s2">&#34;Transfers money to another account. Requires explicit user approval.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">requireConfirmation</span> <span class="p">=</span> <span class="k">true</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">fun</span> <span class="nf">transferFunds</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Recipient account ID&#34;</span><span class="p">)</span> <span class="n">recipientId</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@Param</span><span class="p">(</span><span class="s2">&#34;Amount in USD&#34;</span><span class="p">)</span> <span class="n">amount</span><span class="p">:</span> <span class="n">Double</span>
</span></span><span class="line"><span class="cl"><span class="p">):</span> <span class="n">String</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
</span></span></code></pre></div><p>When execution reaches it, the agent <strong>pauses</strong> and waits for the user to tap confirm in the UI.</p>
<p><strong>If you build finance or healthcare apps, this is close to non-negotiable.</strong></p>
<h2 id="what-it-means-for-kmp-developers">What it means for KMP developers</h2>
<p>Back to that KMP core from the top of the post.</p>
<p>ADK for Kotlin&rsquo;s core engine is cross-platform and decoupled from the model backend, session storage, and memory. The implication of that architectural choice: <strong>agent business logic is shareable</strong>.</p>
<p>Picture it. Your agent orchestration, tool definitions, and SOP skill files run on Android, and they also run on a server JVM. If iOS support lands later, that is one codebase across three targets — which is exactly the story KMP has been telling all along.</p>
<p>Combine that with what we covered on <strong>Kotlin 2.4.20</strong> (sealed class mapping in Swift export, cross-language inheritance), and the puzzle pieces on Kotlin&rsquo;s cross-platform and AI tracks have visibly started fitting together this year.</p>
<p>For anyone building an AI chat client there is a more direct takeaway: <strong>the framework-level pieces — session persistence, context compaction, HITL — already have mature implementations</strong>. You do not have to build them from scratch.</p>
<h2 id="how-to-start">How to start</h2>
<p>Just add the dependencies:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="n">dependencies</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// core engine + KSP processor
</span></span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-core:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ksp</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-processor:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// Android on-device extensions (optional)
</span></span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-mlkit-android:1.0.0-beta&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-litertlm:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">implementation</span><span class="p">(</span><span class="s2">&#34;com.google.adk:google-adk-kotlin-firebase-android:1.0.0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Official resources:</p>
<ul>
<li>📖 Docs and getting started: adk.dev</li>
<li>🐙 GitHub: github.com/google/adk-kotlin</li>
<li>📱 Android guide: Building Android agents with ADK</li>
<li>☕ Java interop sample: ADK Kotlin Java sample</li>
</ul>
<h2 id="wrapping-up">Wrapping up</h2>
<p>I do not think the point of ADK for Kotlin 1.0 is that there is one more agent framework on the pile. The point is this:</p>
<p><strong>The Kotlin ecosystem finally has an agent development story that is compile-time safe, coroutine-native, and reusable across platforms.</strong></p>
<p>It does not drag Python&rsquo;s patterns over. It redesigned around Kotlin&rsquo;s language features: KSP instead of reflection, coroutines instead of callbacks, a KMP core instead of a single-platform implementation.</p>
<p>If you work in KMP, this is worth a serious look.</p>
<hr>
<p><em>Reference: <a href="https://developers.googleblog.com/en/announcing-adk-for-kotlin-10-building-production-ready-ai-agents-in-kotlin-android-and-beyond/">Announcing ADK for Kotlin 1.0 - Google Developers Blog</a> (2026-09-09, by Guillaume Laforge)</em></p>
]]></content:encoded></item><item><title>KMP Lands on HarmonyOS: One Compose Codebase, Four Platforms Now</title><link>http://mings.in/en/posts/kmp-harmony/</link><pubDate>Wed, 16 Sep 2026 21:26:40 +0800</pubDate><guid>http://mings.in/en/posts/kmp-harmony/</guid><description>Start with a number: four.</description><content:encoded><![CDATA[<h2 id="a-quiet-bit-of-map-expansion">A quiet bit of &ldquo;map expansion&rdquo;</h2>
<p>Start with a number: <strong>four</strong>.</p>
<p>Android, iOS, desktop — that&rsquo;s the main ground Kotlin Multiplatform has covered steadily for years (Web / Wasm is still in progress). Write one set of shared logic, add one Compose UI, and several platforms run together.</p>
<p>And yesterday, that list gained a fourth member: <strong>HarmonyOS</strong>.</p>
<p>And this time it isn&rsquo;t &ldquo;theoretically runs&rdquo; — it&rsquo;s an <strong>official release</strong>. Components are in the Maven repo, the toolchain plugin is searchable in the Android Studio plugin marketplace, and the docs are on AtomGit.</p>
<p>What surprised me most was the publisher&rsquo;s line:</p>
<blockquote>
<p>Teams already using CMP on other platforms can reuse the same Compose code for UI and business logic directly, without writing a whole separate ArkUI implementation for HarmonyOS.</p>
</blockquote>
<p><strong>No ArkUI rewrite.</strong> For anyone doing cross-platform, those few words carry real weight.</p>
<h2 id="lets-get-the-facts-straight">Let&rsquo;s get the facts straight</h2>
<p>What shipped is the <strong>first HarmonyOS release of KMP&amp;CMP</strong>, on September 15, 2026.</p>
<p>The key facts:</p>
<ul>
<li><strong>Who made it</strong>: built jointly by community partners in the CPF-KMP-CMP SIG (note: a community SIG, not JetBrains official — I&rsquo;ll get into that distinction later)</li>
<li><strong>Based on</strong>: upstream <strong>KMP 2.2.21 / CMP 1.9.2</strong></li>
<li><strong>Goal</strong>: bring the &ldquo;shared logic, native UI rendering&rdquo; paradigm to HarmonyOS</li>
<li><strong>How to get it</strong>: components are published to the Maven repo; the toolchain plugin <strong>KMP OHOS Support</strong> is searchable and installable from the Android Studio plugin marketplace</li>
</ul>
<p>The official positioning on who it&rsquo;s for is clear too:</p>
<blockquote>
<p>For teams already using KMP/CMP for cross-platform work, this release helps lower the cost of moving to HarmonyOS. HarmonyOS teams can also use it for mixed Kotlin and ArkTS development, and to widen their technology options.</p>
</blockquote>
<p>Translated: <strong>KMP teams who want HarmonyOS now face a lower cost; HarmonyOS teams who want Kotlin now have a path.</strong></p>
<p>Some background while we&rsquo;re here — this has been building for three months. The community edition opened Beta in June, and in August InfoQ reported a round of measurements (95% less rendering memory, 90% fewer GC jank incidents). Now it&rsquo;s properly landed.</p>
<h2 id="highlight-one-compose-runs-on-harmonyos-directly-no-ui-rewrite">Highlight one: Compose runs on HarmonyOS directly, no UI rewrite</h2>
<p>This is the one I think is most valuable.</p>
<p>On HarmonyOS, the official route is <strong>ArkTS + ArkUI</strong>. For a team already doing KMP/CMP, that means the HarmonyOS side needs <strong>its own separate UI</strong> — business logic shared, but the interface rewritten.</p>
<p>This release paves that road:</p>
<blockquote>
<p>CPF-KMP-CMP provides platform implementations of compose.ui, compose.foundation, compose.material and compose.material3 on the HarmonyOS side, so you can keep the familiar declarative UI habits while building HarmonyOS screens.</p>
</blockquote>
<p><strong>compose.material3 is there.</strong> Which is to say, the Compose code you&rsquo;re used to writing is recognized on HarmonyOS.</p>
<p>For a project already running CMP on Android/iOS, that means <strong>the cost of adding HarmonyOS drops from &ldquo;rewrite a UI&rdquo; to &ldquo;add a target&rdquo;</strong>.</p>
<p>How big a difference is that? Anyone who&rsquo;s done cross-platform knows.</p>
<h2 id="highlight-two-kotlin--arkts-two-way-interop">Highlight two: Kotlin ↔ ArkTS two-way interop</h2>
<p>The second one matters a lot for existing projects.</p>
<p>The reality is: plenty of HarmonyOS projects <strong>already have a pile of ArkTS</strong> (system capabilities, business modules), and ripping it all out for Kotlin isn&rsquo;t an option.</p>
<p>The answer here is <strong>akinterop</strong>:</p>
<blockquote>
<p>Shared logic can keep being written in Kotlin. When the system side or existing modules stay in ArkTS, use akinterop for two-way interop: declare intent with annotations / decorators, let the toolchain generate the bridge, and write less C glue. Primitive types, containers and exceptions can cross sides, existing projects can adopt incrementally, and nothing has to be torn down for interop.</p>
</blockquote>
<p><strong>&ldquo;Adopt incrementally, nothing torn down&rdquo;</strong> — that line is the reassurance existing projects needed.</p>
<p>Annotation declares intent, toolchain generates the bridge — a far better experience than hand-writing C glue. Anyone who&rsquo;s written JNI knows that pain.</p>
<h2 id="highlight-three-unified-rendering-this-is-the-technical-core">Highlight three: unified rendering (this is the technical core)</h2>
<p>This one&rsquo;s more hardcore, but I think it&rsquo;s the most technically substantial piece of the whole thing.</p>
<p>The problem first: <strong>traditional cross-platform UI approaches usually build their own rendering pipeline.</strong></p>
<p>What&rsquo;s wrong with building your own? The report says it directly:</p>
<blockquote>
<p>Traditional cross-platform UI solutions often build their own rendering pipeline, which tends to add initialization and composition overhead, and often shows up as slow startup and animations that don&rsquo;t match the system.</p>
</blockquote>
<p>Self-rendering means your UI doesn&rsquo;t go down the system&rsquo;s path — startup needs an extra init step, animation rhythm doesn&rsquo;t line up with the system, and it just looks &ldquo;not native&rdquo;.</p>
<p>This approach goes <strong>the other way</strong>:</p>
<blockquote>
<p>CPF-KMP-CMP connects Compose&rsquo;s drawing capability to HarmonyOS&rsquo;s Render Service / RenderNode system rendering, with HarmonyOS handling composition and refresh scheduling uniformly (unified rendering requires API 19 or above).</p>
</blockquote>
<p><strong>Handing drawing over to HarmonyOS to composite itself</strong> pays off in a chain:</p>
<ul>
<li><strong>Faster first frame</strong>: no self-rendering init wait, so startup and navigation feel snappier</li>
<li><strong>Fine-grained partial refresh</strong>: update only when state changes, with partial recording support — <strong>only the changed region is repainted</strong></li>
<li><strong>Native component mixing</strong>: Compose screens can mix with ArkUI and C-API, so native capabilities like maps and video are easier to embed</li>
<li><strong>System-aligned animation</strong>: frame rhythm follows the system&rsquo;s DisplaySync, so animation matches native HarmonyOS apps</li>
</ul>
<p>That last one matters especially. The reason cross-platform apps look fake at a glance often isn&rsquo;t wrong layout — it&rsquo;s that <strong>the animation rhythm is wrong</strong>, frame scheduling off the system&rsquo;s beat.</p>
<blockquote>
<p>⚠️ Note one limitation: <strong>unified rendering requires API 19 or above</strong>. On older devices it degrades.</p>
</blockquote>
<h2 id="the-performance-account-20-off-compile-time-30-off-heap">The performance account: 20% off compile time, 30% off heap</h2>
<p>They gave some genuinely specific numbers this time, unlike the vague claims that usually pass for &ldquo;performance optimization&rdquo;:</p>
<p><strong>Compile side (parallel compilation):</strong></p>
<blockquote>
<p>Splitting LLVM IR and running local optimization across threads before merging shortens compile waits on large projects; typical scenarios save roughly 20%+ of the time.</p>
</blockquote>
<p><strong>Memory side (CommonGC):</strong></p>
<blockquote>
<p>Using a concurrent mark-copy collection algorithm to reduce memory fragmentation and GC pause time, KN heap usage in typical scenarios drops by about 30%.</p>
</blockquote>
<p>There are also two engineering optimizations that are genuinely practical:</p>
<ul>
<li><strong>Modular splitting</strong>: a single KMP project can be configured into multiple <code>.so</code> files, and combined with distributed compilation it shortens builds further</li>
<li><strong>Debug incremental compilation</strong>: on macOS / Linux, incremental compilation based on a static library cache — <strong>only the changed part recompiles, no full relink every time</strong></li>
</ul>
<p>(Anyone doing KMP probably has a small stress reaction to the words &ldquo;full relink&rdquo;)</p>
<p>One more detail I like — <strong>no idle spinning in the background</strong>:</p>
<blockquote>
<p>The unified rendering path supports requesting VSync signals on demand, actively suppressing refresh drawing when page content hasn&rsquo;t changed; it also supports LTPO hardware characteristics, stopping pointless draw loops automatically once the app goes to the background&hellip; This is framework default behavior, requiring no extra code from the app.</p>
</blockquote>
<p><strong>Default behavior, no app code needed.</strong> This kind of &ldquo;the framework worries about it for you&rdquo; design is far friendlier than making you turn animations off by hand in <code>onPause</code>.</p>
<h2 id="getting-started-three-steps">Getting started: three steps</h2>
<p>The flow is simpler than I expected:</p>
<blockquote>
<p>(1) DevEco Studio: use 6.0.0+ to download HarmonyOS SDK API 17+, open the harmonyApp in the project and set up the debug signature.
(2) Android Studio: write KMP&amp;CMP code day to day.
(3) Android Studio: pick a device and harmonyApp, hit Run (keep DevEco running while pushing the package).</p>
</blockquote>
<p><strong>Note the parenthesis in step 3</strong> — DevEco Studio has to stay open while pushing. That kind of &ldquo;two IDEs cooperating&rdquo; detail is exactly where people trip, and they said it up front.</p>
<p>The docs are on <strong>AtomGit</strong> (search <code>CPF-KMP-CMP/docs</code>) if you want to dig in.</p>
<h2 id="why-this-piece-of-the-harmonyos-puzzle-matters-so-much">Why this piece of the HarmonyOS puzzle matters so much</h2>
<p>Honestly: for KMP developers in China, this puzzle piece may matter far more than Web/Wasm.</p>
<p>The reason is practical — <strong>for mobile work in China, HarmonyOS is no longer a &ldquo;should we consider it&rdquo; question, it&rsquo;s a &ldquo;when do we have to&rdquo; question.</strong> Especially in government and enterprise, finance, automotive and IoT, where it&rsquo;s moving faster than many expected.</p>
<p>And the choice KMP teams faced with HarmonyOS used to be awkward:</p>
<ul>
<li><strong>Shared logic carries over, but the UI gets rewritten</strong> (ArkUI) → which means an extra person on the HarmonyOS side</li>
<li>Or <strong>drop KMP entirely</strong> and start a separate HarmonyOS project → all the shared-logic advantage gone</li>
</ul>
<p>The point of this release is right there: <strong>it takes most of the &ldquo;HarmonyOS UI has to be rewritten too&rdquo; cost out of the equation.</strong></p>
<p>For a small team, that may be the difference between &ldquo;can&rsquo;t afford it&rdquo; and &ldquo;can afford it&rdquo;.</p>
<h2 id="a-cooler-read-how-to-see-this">A cooler read: how to see this</h2>
<p>Hype is hype. I need to be clear about a few boundaries so you don&rsquo;t misjudge.</p>
<p><strong>First, this is a community SIG project, not JetBrains official.</strong></p>
<p>The publisher is the <strong>CPF-KMP-CMP SIG</strong>, not JetBrains and not an official Huawei release. That doesn&rsquo;t make it bad — quite the opposite, this kind of &ldquo;ecosystem foundation&rdquo; usually gets chewed through by community SIGs bit by bit — but <strong>set your expectations accordingly</strong>:</p>
<ul>
<li>Long-term maintenance depends on community activity, without the explicit commitments an official API carries</li>
<li>When you hit a problem, the answer is in the community (issues, discussions), not in official docs</li>
</ul>
<p><strong>Second, unified rendering needs API 19+.</strong></p>
<p>If your target devices include a lot of older HarmonyOS builds, you don&rsquo;t get that biggest performance dividend, so assess your device distribution.</p>
<p><strong>Third, it solves a real problem.</strong></p>
<p>Cross-platform on HarmonyOS used to offer basically two options:</p>
<ul>
<li><strong>Write ArkUI separately for HarmonyOS</strong>, maintaining two UIs</li>
<li><strong>Skip HarmonyOS</strong>, wait for official support</li>
</ul>
<p>Now there&rsquo;s a third: <strong>keep writing shared logic, reuse the Compose UI, bridge existing ArkTS with akinterop.</strong></p>
<p>For a team that&rsquo;s already invested in KMP, the cost-benefit of that third road is hard to ignore.</p>
<h2 id="put-it-back-in-the-arc-of-the-last-few-days">Put it back in the arc of the last few days</h2>
<p>Funny thing: this lines up with two topics from the last couple of days into one thread.</p>
<p>In <strong>the Jake Wharton piece</strong>, he puts it clearly:</p>
<blockquote>
<p>Knowing Kotlin is no longer a career advantage&hellip; but having something more advanced like Kotlin Multiplatform can still be a differentiator.</p>
</blockquote>
<p><strong>The Shopify piece</strong> concludes: once AI makes &ldquo;reimplementing it&rdquo; cheap, the &ldquo;share the UI&rdquo; camp takes the biggest hit, while &ldquo;shared logic + native UI&rdquo; holds up better.</p>
<p>And then today: <strong>KMP&rsquo;s map just gained another region — and a big one.</strong></p>
<p>Three things point the same way: <strong>the value of cross-platform is shifting from &ldquo;save headcount&rdquo; to &ldquo;architecture governance + ecosystem coverage&rdquo;.</strong></p>
<p>Chewing through platforms one at a time was always the core story of the KMP bet. With the HarmonyOS piece filled in, for developers in China especially — <strong>it may be the first time many of them have a real reason to look at KMP seriously.</strong></p>
<hr>
<p>One question to leave you with:</p>
<p><strong>If you can write HarmonyOS UI in Compose, would you still maintain a separate ArkUI codebase for it, or just extend your KMP project to HarmonyOS?</strong></p>
<p>And — for the project in your hands, is HarmonyOS a &ldquo;must do&rdquo; or a &ldquo;let&rsquo;s see&rdquo;?</p>
<p>Comments are open, I really want to know where people actually are.</p>
<hr>
<p><em>References:</em></p>
<ul>
<li><em><a href="https://news.mydrivers.com/1/1151/1151178.htm">First official HarmonyOS release of KMP&amp;CMP (Kuai Technology / Drive Home, 2026-09-15)</a></em></li>
<li><em>Docs: search <code>CPF-KMP-CMP/docs</code> on AtomGit</em></li>
</ul>
]]></content:encoded></item></channel></rss>