<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kanon Fujita</title>
    <description>The latest articles on DEV Community by Kanon Fujita (@874wokiite).</description>
    <link>https://dev.to/874wokiite</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4088277%2F21db95f9-8fbc-418d-9c43-b78237b0208f.png</url>
      <title>DEV Community: Kanon Fujita</title>
      <link>https://dev.to/874wokiite</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/874wokiite"/>
    <language>en</language>
    <item>
      <title>[KMP]What It Took to Ship One Kotlin App to Both Stores</title>
      <dc:creator>Kanon Fujita</dc:creator>
      <pubDate>Tue, 22 Sep 2026 07:38:07 +0000</pubDate>
      <link>https://dev.to/874wokiite/kmpwhat-it-took-to-ship-one-kotlin-app-to-both-stores-4fpn</link>
      <guid>https://dev.to/874wokiite/kmpwhat-it-took-to-ship-one-kotlin-app-to-both-stores-4fpn</guid>
      <description>&lt;p&gt;Afterfade records seven five-second videos over the course of a day and turns them into one lo-fi track. It runs on &lt;a href="https://kotlinlang.org/docs/multiplatform.html" rel="noopener noreferrer"&gt;Kotlin Multiplatform&lt;/a&gt; (KMP) and Compose Multiplatform, live on both the App Store and Google Play. I wrote four posts while building it — this one is the short version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Android took six days to build. iOS — my first iOS app — took two. 70.6% of the code is shared (9,011 of 12,764 lines). The music engine is 2,236 lines of pure Kotlin with zero external dependencies. Platform-specific code is almost entirely camera, video, billing, and ads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commonMain  9,011   androidMain 1,537   iosMain 1,017   Swift 1,199
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything written for iOS is the right two columns: 2,216 lines. → &lt;a href="https://dev.to/874wokiite/kmp-i-chose-kotlin-multiplatform-for-the-ui-sharing-the-audio-engine-is-what-saved-me-178p"&gt;Full story&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  I deleted the server
&lt;/h2&gt;

&lt;p&gt;Music generation started as Python on AWS Lambda — about a minute per track, cost scaling with users, and people's recordings traveling to my server. The engine is pure computation, so I ported it to Kotlin, put it in the shared source set, and it ran on both platforms. Generation now takes about 10 seconds, costs nothing, works in airplane mode, and nothing leaves the device.&lt;/p&gt;

&lt;p&gt;The 52 tests I had written for Android ran on the iOS simulator without changing a line. I moved it on day 5 and deleted the AWS side the next day. → &lt;a href="https://dev.to/874wokiite/kmpno-server-same-track-on-both-platforms-4jci"&gt;Full story&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it broke
&lt;/h2&gt;

&lt;p&gt;An iOS completion callback never returned to Kotlin/Native. I spent a full day on it, then rewrote the video export in Swift. Swift is 1,199 lines today, 9.4% of the codebase. App logic and screens took zero lines of it. → &lt;a href="https://dev.to/874wokiite/where-kotlin-stops-and-swift-starts-in-a-kotlin-multiplatform-app-211o"&gt;Full story&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where your app might land
&lt;/h2&gt;

&lt;p&gt;Three questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On-device computation is the core&lt;/strong&gt; → shareable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You draw your own UI&lt;/strong&gt; → shareable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Camera, maps, or sensors are the point&lt;/strong&gt; → per platform&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already have an Android app in Kotlin: find one piece of logic that calls no OS API, move it into a shared module, and run its existing tests on the iOS simulator. They go green without a change, or they don't — and you know what you are working with.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Afterfade is built for &lt;a href="https://revenuecat-shipaton-2026.devpost.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton 2026&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App Store: &lt;a href="https://apps.apple.com/us/app/afterfade/id6800247416" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/afterfade/id6800247416&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.afterfade.app" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.afterfade.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Detailed posts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://dev.to/874wokiite/kmp-i-chose-kotlin-multiplatform-for-the-ui-sharing-the-audio-engine-is-what-saved-me-178p"&gt;I Had Never Written an iOS App. The iOS Version Took Two Days.&lt;/a&gt; — Choosing KMP, what shared and what didn't&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/874wokiite/where-kotlin-stops-and-swift-starts-in-a-kotlin-multiplatform-app-211o"&gt;Still 90% Kotlin, 9% Swift&lt;/a&gt; — The one piece I could not write in Kotlin&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/874wokiite/cmpone-canvas-drawn-cassette-deck-ui-identical-on-android-and-ios-2bbp"&gt;One Canvas-Drawn Cassette Deck UI, Identical on Android and iOS&lt;/a&gt; — Not a single Material Button; the drawing exists once in commonMain&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/874wokiite/kmpno-server-same-track-on-both-platforms-4jci"&gt;No Server, Same Track on Both Platforms&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>kotlin</category>
      <category>ios</category>
      <category>android</category>
      <category>kotlinmultiplatform</category>
    </item>
    <item>
      <title>[CMP]One Canvas-Drawn Cassette Deck UI, Identical on Android and iOS</title>
      <dc:creator>Kanon Fujita</dc:creator>
      <pubDate>Tue, 22 Sep 2026 07:28:19 +0000</pubDate>
      <link>https://dev.to/874wokiite/cmpone-canvas-drawn-cassette-deck-ui-identical-on-android-and-ios-2bbp</link>
      <guid>https://dev.to/874wokiite/cmpone-canvas-drawn-cassette-deck-ui-identical-on-android-and-ios-2bbp</guid>
      <description>&lt;p&gt;I build Afterfade on my own — seven five-second videos recorded over a day become one lo-fi track, built with Kotlin Multiplatform and Compose Multiplatform for Android and iOS. Background: &lt;a href="https://dev.to/874wokiite/kmp-i-chose-kotlin-multiplatform-for-the-ui-sharing-the-audio-engine-is-what-saved-me-178p"&gt;why KMP&lt;/a&gt; and &lt;a href="https://dev.to/874wokiite/where-kotlin-stops-and-swift-starts-in-a-kotlin-multiplatform-app-211o"&gt;the Kotlin/Swift split&lt;/a&gt;. This post is about the UI.&lt;/p&gt;

&lt;p&gt;Not a single Material &lt;code&gt;Button&lt;/code&gt;, &lt;code&gt;Card&lt;/code&gt; or &lt;code&gt;Icon&lt;/code&gt; in this app. Everything — the mechanical keys, the cassette, the spinning reels — is drawn with &lt;code&gt;Canvas&lt;/code&gt; and hand-written &lt;code&gt;ImageVector&lt;/code&gt;s. The only Material I use is &lt;code&gt;Text&lt;/code&gt; and one &lt;code&gt;AlertDialog&lt;/code&gt;. I wanted the feel identical on both platforms, down to the millimetre, and what makes them match is that they run the same drawing code. The drawing exists once in commonMain. What worries people about Compose Multiplatform is that iOS will look like an Android app. This one looks like a cassette deck on both — there are no platform widgets to drift towards.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmf3rj4j10foox5hvsdi6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmf3rj4j10foox5hvsdi6.png" alt="The same cassette-deck home screen running on iOS (left) and Android (right)" width="799" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did not use Material
&lt;/h2&gt;

&lt;p&gt;The UI is built around a cassette deck and boombox metaphor. Material3's defaults did not fit — off-the-shelf components arrive with appearance and behaviour attached, and anything that does not fit has to be switched off piece by piece. Faster to draw from nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every dimension and colour goes through a token
&lt;/h2&gt;

&lt;p&gt;Corner radii, stroke widths and colours all live as tokens in a 127-line theme definition. No raw &lt;code&gt;dp&lt;/code&gt; values or colour literals appear in screen code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;BoomboxElevation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;          &lt;span class="c1"&gt;// how far a mechanical key travels&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;BoomboxColors&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;Background&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xFFEDEBE8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;RecRed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xFFD6431F&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To change how far a key sinks when pressed, I edit one line, and both platforms sink by the same new amount.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is drawn in Canvas
&lt;/h2&gt;

&lt;p&gt;Roughly 1,800 lines of drawing code live in commonMain — the home screen cassette, the recording deck, the full icon set (hand-written vectors, no icon library), and the mechanical keys. The cassette parts are a shared collection of drawing functions; four screens call into them for the pieces they need.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28tein4nyet7rp8xbynl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28tein4nyet7rp8xbynl.png" alt="Two iPhone screenshots with callouts mapping the five Canvas-drawn parts: on the Home screen, the home screen cassette, the shared cassette parts (screws, reels, tape window, stripes), the three mechanical keys and the icon glyphs inside them; on the generating screen, the recording deck with its REC indicator, counter and level meter." width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the press was built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flb58m7ycu8q2k76c7se1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flb58m7ycu8q2k76c7se1.gif" alt="Pressing the shutter key on the capture screen: the key stays pressed for the five-second countdown, then the processing overlay spins the reel" width="360" height="803"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The buttons recreate the keys on an old boombox — the kind that physically clunk downward when you press them. A flat drop shadow sits behind the key; on press the key body offsets toward it over a 90 ms tween, and a &lt;code&gt;LongPress&lt;/code&gt; haptic fires at the moment of contact.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pressed&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;interactionSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectIsPressedAsState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;travel&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;animateDpAsState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;targetValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pressed&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;isRecording&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;BoomboxElevation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Key&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;animationSpec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;durationMillis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nc"&gt;LaunchedEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pressed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pressed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;haptic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;performHapticFeedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HapticFeedbackType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LongPress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While recording, &lt;code&gt;isRecording&lt;/code&gt; holds the key down. This code lives in commonMain — nothing about the press can drift between platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screen reader labels are written once
&lt;/h2&gt;

&lt;p&gt;Dropping Material means dropping what those components quietly carried: a &lt;code&gt;Button&lt;/code&gt; announces itself, a &lt;code&gt;Canvas&lt;/code&gt; says nothing. So the mechanical keys carry name and role explicitly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clickable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;semantics&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contentDescription&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contentDescription&lt;/span&gt;
            &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Labels come from four-language string resources — declared once, feeding the accessibility tree on both platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Shared-looking" is not the same as shared code
&lt;/h2&gt;

&lt;p&gt;I had split the capture screen with &lt;a href="https://kotlinlang.org/docs/multiplatform-expect-actual.html" rel="noopener noreferrer"&gt;expect/actual&lt;/a&gt; because it touches the camera. The consequence: parts with nothing to do with the camera — the shutter key, the processing reel, the permission screen — got written twice. They looked identical, but only because every fix had to be repeated in both copies.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    subgraph before["Before"]
        A1["Android capture screen&amp;lt;br/&amp;gt;the whole screen"]
        I1["iOS capture screen&amp;lt;br/&amp;gt;the whole screen (near copy)"]
    end
    subgraph after["After"]
        C2["Shared code (commonMain)&amp;lt;br/&amp;gt;shutter key, processing view, permission screen"]
        A2["Android capture screen&amp;lt;br/&amp;gt;camera only"]
        I2["iOS capture screen&amp;lt;br/&amp;gt;camera only"]
        C2 --&amp;gt; A2
        C2 --&amp;gt; I2
    end
    before --&amp;gt; after

    classDef sharedC fill:#d9ead3,stroke:#38761d,color:#000
    classDef platC fill:#f4cccc,stroke:#aa3333,color:#000
    class C2 sharedC
    class A1,I1,A2,I2 platC
    style before fill:#ffffff,stroke:#bbbbbb
    style after fill:#ffffff,stroke:#bbbbbb&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Moving everything that does not touch a camera API to the shared side gave a net reduction of 249 lines and brought the shared portion from 64.6% to 70.6%.&lt;/p&gt;

&lt;h2&gt;
  
  
  UI code written only for iOS
&lt;/h2&gt;

&lt;p&gt;Some things must not be the same. On iOS you swipe from the left edge to go back — without it an app feels wrong. Compose Multiplatform does not provide it, so I wrote it in iosMain. Android does not get this — the left-edge swipe is the OS back gesture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which UIs suit this approach
&lt;/h2&gt;

&lt;p&gt;The more you draw yourself, the larger the share that lives in common code. An app with a world of its own sits well with Compose Multiplatform; an app chasing each platform's stock look gets less out of this.&lt;/p&gt;

&lt;p&gt;If you are choosing between Compose Multiplatform and Flutter for a hand-drawn UI, the drawing will not settle it. What settles it is whether you have something outside the UI worth keeping in the same language — here, 2,200 lines of audio processing next to 1,800 lines of drawing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Afterfade is being built for RevenueCat Shipaton 2026.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App Store: &lt;a href="https://apps.apple.com/us/app/afterfade/id6800247416" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/afterfade/id6800247416&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.afterfade.app" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.afterfade.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>compose</category>
      <category>kotlinmultiplatform</category>
      <category>android</category>
      <category>ios</category>
    </item>
    <item>
      <title>[KMP]No Server, Same Track on Both Platforms</title>
      <dc:creator>Kanon Fujita</dc:creator>
      <pubDate>Sat, 12 Sep 2026 15:23:25 +0000</pubDate>
      <link>https://dev.to/874wokiite/kmpno-server-same-track-on-both-platforms-4jci</link>
      <guid>https://dev.to/874wokiite/kmpno-server-same-track-on-both-platforms-4jci</guid>
      <description>&lt;p&gt;I build an app called Afterfade on my own. You record seven five-second videos over the course of a day, and the app turns them into a single lo-fi track. It ships on both Android and iOS, built with &lt;a href="https://kotlinlang.org/docs/multiplatform.html" rel="noopener noreferrer"&gt;Kotlin Multiplatform&lt;/a&gt; (KMP) and Compose Multiplatform.&lt;/p&gt;

&lt;p&gt;Music generation used to be Python running on AWS. The app extracted audio from the videos, uploaded it, and got a finished track back. There is no server now. Generation happens entirely on the device — about 10 seconds instead of a minute, zero cost per track, works in airplane mode, and nothing you record leaves your phone.&lt;/p&gt;

&lt;p&gt;Why is in an &lt;a href="https://dev.to/874wokiite/kmp-i-chose-kotlin-multiplatform-for-the-ui-sharing-the-audio-engine-is-what-saved-me-178p"&gt;earlier post&lt;/a&gt;; this one is about what I actually did. The part that mattered most: the tests I had written for Android ran on iOS as they were.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup I had, and what bothered me about it
&lt;/h2&gt;

&lt;p&gt;The app pulled audio out of the videos, uploaded it to AWS, and Python on Lambda built a track and sent it back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrs33201zzshk6exni04.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrs33201zzshk6exni04.png" alt="The old architecture: the app uploads audio to AWS, Lambda builds the track and returns it" width="798" height="116"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Afterfade is free and ad-supported. On Lambda in Tokyo, a track costs roughly $0.0023 — about $670/month at 300,000 tracks. The absolute numbers are not the problem; I did not want a setup where cost scales with usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving on-device turns one implementation into two
&lt;/h2&gt;

&lt;p&gt;While generation lives on a server, there is exactly one implementation. Move it onto the device and it becomes two — one for Android, one for iOS. And this app is supposed to be deterministic.&lt;/p&gt;

&lt;p&gt;This is where KMP paid off. Music generation touches no OS API, so I write it once in Kotlin and it runs on both. The ported engine is 2,236 lines in commonMain, importing only &lt;code&gt;kotlin.math&lt;/code&gt;. FFT, filters, and resampling are hand-rolled — zero third-party dependencies. That DSP layer is now public as &lt;a href="https://github.com/874wokiite/afterfade-dsp" rel="noopener noreferrer"&gt;afterfade-dsp&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And because the destination language was Kotlin, the tests came along as they were.&lt;/strong&gt; The 52 tests I had written for the JVM ran on the iOS simulator after nothing more than moving them to the shared source set.&lt;/p&gt;

&lt;p&gt;Only two points on the path to a finished track touch the OS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcnmqq7mp10ha1ex7q3nt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcnmqq7mp10ha1ex7q3nt.png" alt="The on-device pipeline: platform-specific extraction at the top, shared engine in the middle, platform-specific file write at the bottom" width="799" height="158"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The MP4 decoder and the filesystem are the only pieces borrowed from the OS. About 86% of the music path is shared Kotlin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do both platforms really produce the same track?
&lt;/h2&gt;

&lt;p&gt;I ran those 52 tests on the iOS simulator and 51 passed. One accompaniment did not match its expected value. The cause was this line, which converts a MIDI note number to a frequency.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;midiToHz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;440.0&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;midi&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;69&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pow&lt;/code&gt; is not required by its specification to return a correctly rounded result. The JVM and Apple's libm disagree on the last bit for some inputs. There are only twelve semitone ratios, so I dropped &lt;code&gt;pow&lt;/code&gt; and replaced it with twelve correctly rounded constants. This looks like an audio-only problem, but it happens anywhere you compare floating-point results across platforms.&lt;/p&gt;

&lt;p&gt;Had I written this twice, in Kotlin and in Swift, I would have had to build the verification tool before I could check anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which workloads can lose their server?
&lt;/h2&gt;

&lt;p&gt;The bill was not the only thing that went away — so did API versioning, timeouts, retries, cold-start workarounds, and the code that guaranteed uploaded audio was deleted.&lt;/p&gt;

&lt;p&gt;My rule: the work has to be pure computation. The input is already on the device, the output follows from that input, and there is no large asset like a trained model to ship. If all three hold, there is not much reason to keep it on a server.&lt;/p&gt;

&lt;p&gt;Since the day I deleted the server, I have not thought about the bill once. The setup I picked to share the UI turned out to help most outside it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Afterfade is being built for RevenueCat Shipaton 2026.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App Store: &lt;a href="https://apps.apple.com/us/app/afterfade/id6800247416" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/afterfade/id6800247416&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.afterfade.app" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.afterfade.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kotlin</category>
      <category>mobile</category>
      <category>audio</category>
      <category>kotlinmultiplatform</category>
    </item>
    <item>
      <title>[KMP] I Had Never Written an iOS App. The iOS Version Took Two Days</title>
      <dc:creator>Kanon Fujita</dc:creator>
      <pubDate>Sat, 29 Aug 2026 16:22:51 +0000</pubDate>
      <link>https://dev.to/874wokiite/kmp-i-chose-kotlin-multiplatform-for-the-ui-sharing-the-audio-engine-is-what-saved-me-178p</link>
      <guid>https://dev.to/874wokiite/kmp-i-chose-kotlin-multiplatform-for-the-ui-sharing-the-audio-engine-is-what-saved-me-178p</guid>
      <description>&lt;p&gt;I'm building an app called Afterfade on my own — seven five-second videos recorded over a day become one lo-fi track. The iOS version is live on the App Store; the Android version is in review.&lt;/p&gt;

&lt;p&gt;I had some Android experience but had never built an iOS app. With only evenings and weekends, implementation came to six days for Android and two for iOS. Eight days total.&lt;/p&gt;

&lt;p&gt;It is built with &lt;a href="https://kotlinlang.org/docs/multiplatform.html" rel="noopener noreferrer"&gt;Kotlin Multiplatform&lt;/a&gt; (KMP) and Compose Multiplatform — iOS support went stable in May 2025. This post covers why I picked KMP, and what did and did not end up shared.&lt;/p&gt;

&lt;h2&gt;
  
  
  I counted the days to the store before writing anything
&lt;/h2&gt;

&lt;p&gt;This was built for &lt;a href="https://revenuecat-shipaton-2026.devpost.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton&lt;/a&gt;, a two-month hackathon that requires you to be live on a store. Google Play demands &lt;a href="https://support.google.com/googleplay/android-developer/answer/14151465" rel="noopener noreferrer"&gt;14 consecutive days&lt;/a&gt; of 12 testers in a closed test before you can even request production access — weeks pass while you do nothing. So: finish Android first, start the closed test, and spend those unmovable 14 days building iOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options for shipping to two platforms alone
&lt;/h2&gt;

&lt;p&gt;Writing each one natively doubles the work. Flutter or React Native let you write once, but the language becomes Dart or TypeScript. KMP + &lt;a href="https://www.jetbrains.com/compose-multiplatform/" rel="noopener noreferrer"&gt;Compose Multiplatform&lt;/a&gt; keeps the language as Kotlin. I already knew Kotlin, so this is what I picked.&lt;/p&gt;

&lt;p&gt;What paid off later was that for the first week I wrote a plain Android app without thinking about iOS at all, then added the iOS target. &lt;strong&gt;Pick Flutter or React Native and that week starts with rewriting in Dart or TypeScript. If you already have an Android app, KMP is the only option where the second platform is something you add rather than something you restart.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  You can drop down to native later, and only where you need it
&lt;/h3&gt;

&lt;p&gt;KMP gives you &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt;: shared code declares a capability, and each platform writes the implementation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain (shared by both platforms) — declaration only&lt;/span&gt;
&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;InterstitialAdEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;show&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;placement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;onShown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="n"&gt;onComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation calls that platform's APIs directly — the iOS camera is 320 lines of Kotlin calling &lt;code&gt;AVFoundation&lt;/code&gt; as-is, the same API names you would write in Swift. This is also the escape hatch when an SDK has no KMP support; RevenueCat and AdMob have no shared KMP SDK, so each platform uses its official native SDK directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets shared and what does not
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fler91i7qrkdd3uppvivq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fler91i7qrkdd3uppvivq.png" alt="App structure: commonMain (green), shared by both platforms, holds the music engine, 2,236 lines with no external dependencies, and the screens and app logic, 6,775 lines of Compose Multiplatform. Platform-specific code (red) holds audio extraction from video, camera, playback and export, and the billing and ad SDKs. Only one arrow crosses between them, from the engine to audio extraction." width="800" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only one line runs from the engine into red: pulling audio data out of a video file. Everything past that — analysis, arrangement, mixing, rendering — is shared. This app draws every component by hand, so all that polish lands on both platforms at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving the music engine onto the device
&lt;/h2&gt;

&lt;p&gt;Music generation originally ran in Python on AWS Lambda — slow, costly, offline-unusable, and users' videos had to leave the device. The engine is pure computation with no external dependencies, so writing it in Kotlin means it runs on both platforms as-is. I ported it on day 5 and deleted the server the next day.&lt;/p&gt;

&lt;h2&gt;
  
  
  One review comment fixed both platforms and four languages
&lt;/h2&gt;

&lt;p&gt;App Store review flagged my permission flow: once you show a pre-permission explanation, it has to lead straight into the system dialog, with no path to defer. The Android version had the same loose flow, and that screen lives in shared code. &lt;strong&gt;I fixed both at once.&lt;/strong&gt; The copy lives in one shared resource set, so four languages were a single change too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not get shared
&lt;/h2&gt;

&lt;p&gt;Only two things ate real time, and both happened where I tried to write platform-specific work in shared code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Video export got rewritten in Swift
&lt;/h3&gt;

&lt;p&gt;iOS's asynchronous APIs and Kotlin made video export hang forever. I wrapped &lt;code&gt;AVAssetExportSession&lt;/code&gt;'s callback in &lt;code&gt;suspendCancellableCoroutine&lt;/code&gt;; the callback never fired and the coroutine waited forever. I spent a day on it, then rewrote the AV work in Swift. The iOS "two days" includes that day.&lt;/p&gt;

&lt;p&gt;Swift is 1,199 lines, 9.4% of the total — all video work and billing/ad SDK calls. I wrote that up in &lt;a href="https://dev.to/874wokiite/where-kotlin-stops-and-swift-starts-in-a-kotlin-multiplatform-app-211o"&gt;another post&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Some of the iOS feel comes for free, some does not
&lt;/h3&gt;

&lt;p&gt;Haptics and the scrolling photo grid worked on both from shared code. What did not come for free was the swipe-back gesture (52 lines) and making the iPad share sheet appear as a popover. &lt;strong&gt;This app draws every component itself, though. Standard UI components would have more gaps to fill.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2,216 lines were all the second platform needed
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commonMain  9,011   androidMain 1,537   iosMain 1,017   Swift 1,199
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything written for iOS is the two columns on the right: 2,216 lines. 70.6% shared, and the ~3,700 platform-specific lines are almost entirely camera, video, billing and ads. Estimated two weeks for Android took six days; estimated ten days for iOS took two.&lt;/p&gt;

&lt;p&gt;I got a first iOS app onto the App Store because only 2,216 lines were left. Shipping to a second platform is a question about the store, not about how much there is to build.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Afterfade is being built for RevenueCat Shipaton 2026.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App Store: &lt;a href="https://apps.apple.com/us/app/afterfade/id6800247416" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/afterfade/id6800247416&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.afterfade.app" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.afterfade.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kmp</category>
      <category>ios</category>
      <category>android</category>
      <category>swift</category>
    </item>
    <item>
      <title>[KMP] Still 90% Kotlin, 9% Swift</title>
      <dc:creator>Kanon Fujita</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:18:00 +0000</pubDate>
      <link>https://dev.to/874wokiite/where-kotlin-stops-and-swift-starts-in-a-kotlin-multiplatform-app-211o</link>
      <guid>https://dev.to/874wokiite/where-kotlin-stops-and-swift-starts-in-a-kotlin-multiplatform-app-211o</guid>
      <description>&lt;p&gt;I build an app called Afterfade on my own. You record seven five-second videos over the course of a day, and the app turns them into a single lo-fi track. It runs on both Android and iOS, built with &lt;a href="https://kotlinlang.org/docs/multiplatform.html" rel="noopener noreferrer"&gt;Kotlin Multiplatform&lt;/a&gt; (KMP) and Compose Multiplatform. Swift and AVFoundation were new to me. Implementation still took six days for Android and two days for iOS (&lt;a href="https://dev.to/874wokiite/kmp-i-chose-kotlin-multiplatform-for-the-ui-sharing-the-audio-engine-is-what-saved-me-178p"&gt;previous post&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Then the video export hung forever on iOS, and the fix was to stop writing that part in Kotlin. If you are considering KMP, what you are probably worried about is exactly that: what happens when you hit something Kotlin cannot handle cleanly. It turned out to cost one folder of Swift. The UI, the music engine, and the Android version were untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The video export never returned
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fns0n9s3toofph2v0ukvr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fns0n9s3toofph2v0ukvr.png" alt="Afterfade ui image" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After collecting seven clips, the app generates a track and exports a video stitching them together. That export never returned — no crash, nothing in the logs. The same flow worked fine on Android.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the iOS export implementation at the time, the relevant part&lt;/span&gt;
&lt;span class="nf"&gt;suspendCancellableCoroutine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cont&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;exportSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportAsynchronouslyWithCompletionHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exportSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;AVAssetExportSessionStatusCompleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;cont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invokeOnCancellation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;exportSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelExport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.apple.com/documentation/avfoundation/avassetexportsession" rel="noopener noreferrer"&gt;&lt;code&gt;AVAssetExportSession&lt;/code&gt;&lt;/a&gt; reports completion through a completion handler, so the call is wrapped in &lt;a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/suspend-cancellable-coroutine.html" rel="noopener noreferrer"&gt;&lt;code&gt;suspendCancellableCoroutine&lt;/code&gt;&lt;/a&gt;. When it does not work, the handler is never invoked, and the coroutine waits forever with no exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried, and why none of it worked
&lt;/h2&gt;

&lt;p&gt;I changed the dispatcher and wrapped the call in &lt;code&gt;withTimeoutOrNull&lt;/code&gt;. Neither fixed the hang. I never worked out the root cause — my best guess is object lifetime — but it did not matter. The iOS export shared nothing with Android; it was iOS-specific &lt;code&gt;AVMutableComposition&lt;/code&gt; and &lt;code&gt;AVAssetExportSession&lt;/code&gt; while the Android version was entirely separate code on &lt;a href="https://developer.android.com/media/media3/transformer" rel="noopener noreferrer"&gt;Media3 Transformer&lt;/a&gt;. Being able to call an API from Kotlin/Native is not a reason to do it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two questions for deciding what goes to Swift
&lt;/h2&gt;

&lt;p&gt;Since then, I ask two questions whenever I add something to the iOS side. &lt;strong&gt;Can Android reuse the same code?&lt;/strong&gt; If not, there is nothing to share by writing it in Kotlin. &lt;strong&gt;Does the API call you back when it finishes?&lt;/strong&gt; Bridging a completion handler into a coroutine works most of the time; when it does not, it fails silently. In either case, write it in Swift.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14ekwn3pfo0z9wysae2v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14ekwn3pfo0z9wysae2v.png" alt="Decision flowchart for new iOS work: if Android cannot reuse the code, if the API calls you back, if you must release memory yourself, or if it is a UI component, write it in Swift; otherwise write it in Kotlin." width="799" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hang happened because I answered the first question wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  NativeBridge: how Kotlin calls Swift
&lt;/h2&gt;

&lt;p&gt;Swift can call into Kotlin, but the usual setup gives you nothing for the opposite direction. So the Kotlin side declares empty slots, and Swift injects the real implementations at startup as lambdas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iosMain (iOS-only Kotlin)&lt;/span&gt;
&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;NativeBridge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;exportFilm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FilmExportFn&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;createPlayerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlayerViewFactory&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;extractAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AudioExtractFn&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; entry points did not change, so all I swapped were the iOS-specific implementation files. Swift and Kotlin never land in the same file, and a change on either side cannot quietly alter the other without crossing a shared signature. Embedding into Compose is one call — &lt;code&gt;UIKitView(factory = { NativeBridge.createPlayerView(...) })&lt;/code&gt; with &lt;a href="https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-uikit-integration.html" rel="noopener noreferrer"&gt;&lt;code&gt;UIKitView&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the iOS side ended up divided
&lt;/h2&gt;

&lt;p&gt;Applying those questions: video composition, audio extraction, sharing, dominant colour extraction, and video playback all went to Swift — they either call back on completion or manage their own object lifetimes. The camera capture session stayed in Kotlin, because everything from &lt;a href="https://developer.apple.com/documentation/avfoundation/avcapturesession" rel="noopener noreferrer"&gt;&lt;code&gt;AVCaptureSession&lt;/code&gt;&lt;/a&gt;'s &lt;code&gt;beginConfiguration()&lt;/code&gt; to &lt;code&gt;commitConfiguration()&lt;/code&gt; completes as you call it.&lt;/p&gt;

&lt;h2&gt;
  
  
  On iOS too, 90% of the code is still Kotlin
&lt;/h2&gt;

&lt;p&gt;Here is the current breakdown (measured September 2, 2026).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commonMain 9,011   androidMain 1,537   iosMain 1,017   Swift 1,199
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against those 12,764 lines, commonMain accounts for 70.6%. Swift is 9.4% — AVFoundation calls and NativeBridge registrations. The music engine is about 2,200 lines of plain Kotlin in commonMain, no external dependencies, no platform branches (the DSP layer is public as &lt;a href="https://github.com/874wokiite/afterfade-dsp" rel="noopener noreferrer"&gt;afterfade-dsp&lt;/a&gt;). The same input produces the same track on both platforms, verified down to bit-identical waveforms.&lt;/p&gt;

&lt;p&gt;You do not need to work out how much will fit in Kotlin before choosing KMP. Whatever does not fit gets handed over afterwards, inside the platform-specific files. The worst case I hit was 1,199 lines of Swift, an untouched Android version, and a shared portion that went up rather than down.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Afterfade is being built for RevenueCat Shipaton 2026. I plan to write separately about the music engine and the UI.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App Store: &lt;a href="https://apps.apple.com/us/app/afterfade/id6800247416" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/afterfade/id6800247416&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.afterfade.app" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.afterfade.app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kotlin</category>
      <category>swift</category>
      <category>kotlinmultiplatform</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
