<?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] I Chose Kotlin Multiplatform for the UI. Sharing the Audio Engine Is What Saved Me.</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 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.&lt;/p&gt;

&lt;p&gt;The occasion was &lt;a href="https://revenuecat-shipaton-2026.devpost.com/" rel="noopener noreferrer"&gt;RevenueCat Shipaton&lt;/a&gt;, a two-month hackathon running from August 1 to September 30. I started building on July 31, 2026, and the iOS version went live on the App Store within August. The Android version is coming soon.&lt;/p&gt;

&lt;p&gt;I had done some Android development before, but I had never built an iOS app. I also have a full-time job, so the only time I can put into this is evenings and weekends.&lt;/p&gt;

&lt;p&gt;This post is about why I chose &lt;a href="https://kotlinlang.org/docs/multiplatform.html" rel="noopener noreferrer"&gt;Kotlin Multiplatform&lt;/a&gt;, not as a feature comparison but as a countdown of the days I actually had. It then covers the use I had not planned for, which is the one that ended up helping most, and the things I did not notice until I had shipped. Two months sounds like a lot, but a bit of math before starting showed that the time available for writing code was much shorter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deadline is not the submission date, it is the day you are live on a store
&lt;/h2&gt;

&lt;p&gt;Under the Shipaton rules, the app's first public release has to happen within the contest window, and the app has to be live on the App Store or Google Play at submission time.&lt;/p&gt;

&lt;p&gt;So before anything else, I worked out how many days it takes to get onto each store.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Time it takes&lt;/th&gt;
&lt;th&gt;Nature of the wait&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recruit 12 testers&lt;/td&gt;
&lt;td&gt;A few days to unknowable&lt;/td&gt;
&lt;td&gt;You are asking people for a favour; working faster does not speed it up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Play closed test&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14+ days&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12 or more testers must stay opted in for 14 consecutive days (&lt;a href="https://support.google.com/googleplay/android-developer/answer/14151465" rel="noopener noreferrer"&gt;the official testing requirements&lt;/a&gt;). The days themselves are the requirement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Play production access review&lt;/td&gt;
&lt;td&gt;About a week&lt;/td&gt;
&lt;td&gt;You apply only after the closed test is done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Store review&lt;/td&gt;
&lt;td&gt;Around 2 days&lt;/td&gt;
&lt;td&gt;Apple says &lt;a href="https://developer.apple.com/distribute/app-review/" rel="noopener noreferrer"&gt;90% of submissions are reviewed within 24 hours&lt;/a&gt;, though I have heard of it taking over a week, so leave a margin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the Android side, more than three weeks pass just by waiting. The 14-day closed test is the big one.&lt;/p&gt;

&lt;p&gt;Recruiting 12 testers is also a real hurdle for a solo developer. You have to ask people, get them to install the app, and have them &lt;strong&gt;stay opted in for 14 straight days&lt;/strong&gt;. How many will say yes, and how long it takes, is not up to you. If this step slips, everything behind it slips too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Android first, build iOS during the test window
&lt;/h2&gt;

&lt;p&gt;That structure of waiting decided the order for me.&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%2F1inaonz2gix8nft43fhm.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%2F1inaonz2gix8nft43fhm.png" alt="Gantt chart of the original plan: 5 days of ideation, then 2 weeks of Android implementation leading into a 14-day closed test and a week-long production access review, while the iOS version is implemented and reviewed during the closed test window, putting both stores live by early September." width="799" height="273"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The red bars are the waits I cannot shorten myself.&lt;/p&gt;

&lt;p&gt;If I finish Android first and get the closed test running, those 14 days become the window for building the iOS version. Do it the other way around and Android's 14 days simply shift later, with a real chance of missing the end of September.&lt;/p&gt;

&lt;p&gt;With a day job on weekdays, I budgeted 5 days of ideation before implementation, then 2 weeks of implementation for Android and 10 days for iOS. On that plan, both versions are live on their stores by early September.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write Android and iOS natively, separately&lt;/td&gt;
&lt;td&gt;Double the work. Building the iOS version solo within the 14-day closed test window is a stretch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutter or React Native&lt;/td&gt;
&lt;td&gt;Fast for UI. But the heart of this app is audio processing, not UI, and that part would have to be rewritten in yet another language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kotlin Multiplatform + &lt;a href="https://www.jetbrains.com/compose-multiplatform/" rel="noopener noreferrer"&gt;Compose Multiplatform&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;I already knew Kotlin, so this is what I picked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I did consider Flutter and React Native, but the decision came down to where the app's value lives. If the screens and the experience had been the main event, I might well have picked one of those.&lt;/p&gt;

&lt;p&gt;At the time I chose, I was looking at exactly three things: I am one person shipping to two platforms, I already write Kotlin comfortably, and the UI and the app's core only have to be written once. How to handle music generation was not part of the decision. As I explain below, it was going to run on a server anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  After choosing, the engine moved on-device
&lt;/h2&gt;

&lt;p&gt;That is the technology-selection story. What actually saved me was a part I had not been thinking about when I chose.&lt;/p&gt;

&lt;p&gt;When I started, music generation ran in Python on AWS Lambda. The app uploads the videos, the server sends back a track. In that shape, Android and iOS both call the same server and get the same track back, so the question of what language to write the generator in never comes up.&lt;/p&gt;

&lt;p&gt;But the setup had problems.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uploading seven five-second videos means a lot of transfer and a lot of waiting&lt;/li&gt;
&lt;li&gt;As a solo developer, every new user's compute cost lands directly on me&lt;/li&gt;
&lt;li&gt;Offline, the app can do nothing&lt;/li&gt;
&lt;li&gt;Users' videos get sent to my server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lining those four up settled the direction: &lt;strong&gt;generation should happen entirely on the device.&lt;/strong&gt; The question was how. The normal way is to write the same processing twice, once per platform. Rewriting audio processing in two languages, and keeping the output identical between them, was not realistic with the days I had left.&lt;/p&gt;

&lt;p&gt;One common way to share audio code is to write it in C++ and call it from both platforms. It is a standard choice for audio work, but the build setup takes time and I already had Kotlin experience, so I passed on it this time.&lt;/p&gt;

&lt;p&gt;This is where already being on Kotlin Multiplatform saved me. The engine is pure computation with no external dependencies, so writing it in Kotlin means it simply runs on both platforms. The server had guaranteed the same track on either OS, and that property survived the move onto the device.&lt;/p&gt;

&lt;p&gt;In other words, the setup I had chosen to share UI turned out to also be the way to share the audio processing. &lt;strong&gt;I had not anticipated that use when I made the choice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On day 5 of the project I ported the engine to Kotlin, and the next day I deleted the server. The C++ comparison and the porting itself deserve their own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ended up shared: the engine and the UI
&lt;/h2&gt;

&lt;p&gt;The ported engine is about 2,200 lines of Kotlin in &lt;code&gt;commonMain&lt;/code&gt; (the shared source set used by both Android and iOS). It has no external dependencies, and the only thing it uses from the standard library is &lt;code&gt;kotlin.math&lt;/code&gt;.&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%2Fzxkh6zni1idnzemtwull.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%2Fzxkh6zni1idnzemtwull.png" alt="App structure: commonMain (green) holds the music engine, about 2,200 lines with no external dependencies, and the Compose Multiplatform UI, while platform-specific code (red) covers audio extraction from video and the camera, playback and export. Only one line connects the engine to platform code." width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Green is code shared by both platforms, red is code written per platform. Exactly one line runs from the engine to OS-dependent code, and that is the part that pulls audio data out of a video file. Everything past that point runs as shared code: analysis, arrangement, mixing, rendering.&lt;/p&gt;

&lt;p&gt;The random number generator is also self-implemented (SplitMix64), so the same input always produces the same track. Which also means both platforms run the same generation logic and produce tracks of the same quality.&lt;/p&gt;

&lt;p&gt;In hindsight, sharing the engine is the thing I am most glad KMP gave me. But I only realised that later. While I was building, the benefit I could actually feel was on the UI side.&lt;/p&gt;

&lt;p&gt;The app looks like a cassette deck. It uses almost none of Material's default components; most of what is on screen is drawn by hand, down to details like how a key sinks when pressed and the reels that spin during processing. Keeping that level of polish identical across two platforms is normally a lot of work. With Compose Multiplatform, each adjustment is made once and lands on both.&lt;/p&gt;

&lt;p&gt;The more the UI is worth fussing over, the more sharing it pays off.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long it actually took
&lt;/h2&gt;

&lt;p&gt;Here is how the implementation time came out (as of August 21, 2026).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Estimated&lt;/th&gt;
&lt;th&gt;Actual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;2 weeks&lt;/td&gt;
&lt;td&gt;6 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;10 days&lt;/td&gt;
&lt;td&gt;2 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Considerably faster than planned. The iOS version took two days because the UI and the music engine were already shared, and what remained to write was only the OS-specific part: camera, playback, export. Starting from zero iOS experience and having something working in two days came down to how much did not have to be written.&lt;/p&gt;

&lt;p&gt;The current line counts (as of August 28, 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  7,799   androidMain 1,813   iosMain 1,298   Swift 1,156
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works out to 64.6% shared.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I noticed only after shipping
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Shipping to iOS first surfaces Android's problems early
&lt;/h3&gt;

&lt;p&gt;The plan was "finish Android first, build iOS during the test window", but the store submission happened on iOS first, and so did the release.&lt;/p&gt;

&lt;p&gt;App Store review flagged how my camera and microphone permission flow was built. The gist: once you show a pre-permission explanation screen, it must lead directly into the system permission dialog, with no path for the user to defer it.&lt;/p&gt;

&lt;p&gt;I rebuilt the permission flow, and not only on iOS. The design being loose about permissions was just as true of the Android version, so &lt;strong&gt;I fixed the same screen on both at once&lt;/strong&gt;. The copy lives in shared resources, so the fix across four languages was also a single change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixes made during the closed test doubled as iOS improvements
&lt;/h3&gt;

&lt;p&gt;The plan was to spend the closed test's 14 days entirely on iOS implementation. In practice, testers reported things, I found things I wanted to fix myself, and I shipped several Android updates during that window. It did not go purely to iOS as planned.&lt;/p&gt;

&lt;p&gt;But most of what I fixed lived in the shared code. Work done for the Android version became improvements to the iOS version at the same time. The test window did eat into implementation time, but none of that work had to be done twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Work that was missing from the estimate
&lt;/h3&gt;

&lt;p&gt;The combination of iOS's asynchronous APIs and Kotlin cost me a day. A video export stopped returning forever, and finding and working around the cause took time. In the end I rewrote the iOS-specific AV work in Swift. That story is in another post.&lt;/p&gt;

&lt;p&gt;Nothing outside the code was shared. Signing, build settings and uploading to TestFlight are ordinary iOS work and all of it still happens, and review responses, screenshots and policy compliance happen once per store. That turned out to be tougher than I expected. I will write separately about where KMP helped and where it did not.&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: in review&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kmp</category>
      <category>ios</category>
      <category>android</category>
      <category>swift</category>
    </item>
    <item>
      <title>[KMP] Where Kotlin Stops and Swift Starts in a Kotlin Multiplatform App</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 Kotlin Multiplatform and Compose Multiplatform. Implementation took six days for Android and two days for iOS.&lt;/p&gt;

&lt;p&gt;This post is about a bug I hit during development, and how it led me to a rule for deciding how much of the iOS side to write in Kotlin and where to hand off to Swift.&lt;/p&gt;

&lt;p&gt;One thing worth knowing up front: I had never built an iOS app before. Swift and AVFoundation were both new to me on this project. The reason I could still ship the iOS version in two days is that the UI and the music engine were already shared with Android.&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;Once seven five-second videos have been collected, the app generates a track and then exports a single video that stitches the clips together in time with it. That export never returned.&lt;/p&gt;

&lt;p&gt;While it runs, the app shows a loading state, and it simply stayed there. No crash, nothing in the logs. The same flow worked fine on Android, and it only reproduced on iOS. I will refer to this state, where a process never returns and the app sits waiting forever, as a hang.&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%2F3qtjwvyz34l8dmx1xw0i.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%2F3qtjwvyz34l8dmx1xw0i.png" alt="Sequence diagram showing the hang: the Kotlin coroutine calls exportAsynchronouslyWithCompletionHandler and waits, the completion handler is never called, so cont.resume() is never reached and the UI stays on the loading state." width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The part that hung is shown in red below. Green is code shared by both platforms, blue and orange are code specific to each one.&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%2F250w3ntp4oube8edmijh.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%2F250w3ntp4oube8edmijh.png" alt="App structure: commonMain (green) holds the Compose screens and the music engine, while the video export is split into an Android-only path using Media3 Transformer (blue) and an iOS-only path using AVAssetExportSession (red), where the hang happened." width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The export is not shared between the two platforms. Here is what the iOS side looked like at the time.&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;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;generateFilm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&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="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... build the AVMutableComposition ...&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;exportSession&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AVAssetExportSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;asset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;composition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;presetName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AVAssetExportPresetMediumQuality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@withContext&lt;/span&gt; &lt;span class="k"&gt;false&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;outputURL&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outputUrl&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;outputFileType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AVFileTypeMPEG4&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AVAssetExportSession&lt;/code&gt; reports completion through a completion handler, so the call is wrapped in &lt;code&gt;suspendCancellableCoroutine&lt;/code&gt; to expose it as a Kotlin suspend function. This seems to be the common way to bridge a native async API from Kotlin/Native, and it apparently works most of the time.&lt;/p&gt;

&lt;p&gt;When it does not work, the completion handler is never invoked. &lt;code&gt;cont.resume(...)&lt;/code&gt; is never reached, so the coroutine waits forever. No exception is thrown either, which left me with no indication of where it had stopped.&lt;/p&gt;

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

&lt;p&gt;I suspected the dispatcher first. I switched from &lt;code&gt;Dispatchers.IO&lt;/code&gt; to &lt;code&gt;Dispatchers.Main&lt;/code&gt;, and I tried pushing the work onto the main thread explicitly with &lt;code&gt;dispatch_async(dispatch_get_main_queue())&lt;/code&gt;. The behaviour changed, but the hang did not go away.&lt;/p&gt;

&lt;p&gt;Then I added a timeout. Wrapping the call in &lt;code&gt;withTimeoutOrNull&lt;/code&gt; should at least have prevented it from waiting forever. That did not work either. The export still never came back with the timeout in place.&lt;/p&gt;

&lt;p&gt;In the end I stopped using the completion handler altogether and polled &lt;code&gt;exportSession.status&lt;/code&gt; every 200 milliseconds instead. That worked.&lt;/p&gt;

&lt;p&gt;It is a poor fix, though. It throws away the completion callback the API already provides and checks the state on a timer instead, which means up to 200 milliseconds of delay after the export finishes, and it also makes the failure reason harder to surface.&lt;/p&gt;

&lt;p&gt;The same thing takes a few lines in Swift with &lt;code&gt;async/await&lt;/code&gt;. Even without any iOS experience, it is the kind of code you can get working by following the sample in the documentation. Instead I had 30 lines of Kotlin that were not receiving the completion callback correctly.&lt;/p&gt;

&lt;p&gt;I never worked out why the completion handler was not invoked, or why &lt;code&gt;withTimeoutOrNull&lt;/code&gt; had no effect. I stopped chasing the cause there and went looking at something else instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did I pick Kotlin Multiplatform in the first place?
&lt;/h2&gt;

&lt;p&gt;The question I should have been asking was not how to fix it, but why this code was in Kotlin at all.&lt;/p&gt;

&lt;p&gt;The reason to use Kotlin Multiplatform is to share code. So I went back and checked what &lt;code&gt;FilmGenerator.ios.kt&lt;/code&gt; actually shared with Android.&lt;/p&gt;

&lt;p&gt;It shared nothing. The file was iOS-specific work built on &lt;code&gt;AVMutableComposition&lt;/code&gt; and &lt;code&gt;AVAssetExportSession&lt;/code&gt;, while the Android version was entirely separate code built on Media3 Transformer. The only thing the two had in common was the function signature.&lt;/p&gt;

&lt;p&gt;I had put code that cannot be shared on top of the machinery for sharing it. There was no benefit to gain, and what I was left with was the overhead of cinterop, the mechanism that lets Kotlin call C and Objective-C APIs, plus bugs caused by the two languages disagreeing about how asynchronous work should be awaited.&lt;/p&gt;

&lt;p&gt;Whether Kotlin/Native can call an API is not a reason to write it in Kotlin. That was my mistake here.&lt;/p&gt;

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

&lt;p&gt;Since then, I ask myself these two questions in order whenever I add something to the iOS side. They live in the project's rules file.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Can Android reuse the same code?
&lt;/h3&gt;

&lt;p&gt;If it cannot, there is nothing to share by writing it in Kotlin, and all that remains is the cost of reaching iOS APIs from Kotlin. If it cannot be reused, write it in &lt;strong&gt;Swift&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Does the API call you back when it finishes?
&lt;/h3&gt;

&lt;p&gt;Many iOS APIs return control immediately and call you back later through a completion handler, a delegate, or GCD. That model and the way Kotlin coroutines wait are built on different assumptions, and when they fail to line up you get a silent hang like this one. If the API calls you back, write it in &lt;strong&gt;Swift&lt;/strong&gt;.&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 first question settles it most of the time. The second is there to catch what the first one misses, and that is exactly what happened with the hang above: I missed the first question, and wrote something matching the second without noticing.&lt;/p&gt;

&lt;p&gt;There are two more kinds of work that go to Swift without needing a question. One is APIs like CoreGraphics, where you release objects with &lt;code&gt;CFRelease&lt;/code&gt; once you are done; Kotlin manages memory for you by default, so mixing manual release into it means tracking leaks and double frees by hand. The other is an iOS UI component itself, a &lt;code&gt;UIView&lt;/code&gt; subclass such as a video player or a camera preview, which is simpler to build in Swift and embed from Compose.&lt;/p&gt;

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

&lt;p&gt;Handing work to Swift is not as simple as calling a Swift function from Kotlin, because in the usual setup that direction does not exist. In KMP, Swift depends on the Kotlin framework and not the other way around.&lt;/p&gt;

&lt;p&gt;So the Kotlin side declares empty slots to be filled in later, and Swift injects the real implementations when the app starts. What goes into those slots is not a value but the work itself, as a lambda.&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;// composeApp/src/iosMain/.../bridge/NativeBridge.kt&lt;/span&gt;
&lt;span class="k"&gt;typealias&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="n"&gt;clipPaths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ... the rest of the export arguments ...&lt;/span&gt;
    &lt;span class="n"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;:&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="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="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&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="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iosApp/iosApp/iOSApp.swift&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;setupNativeBridge&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;bridge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NativeBridge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;

    &lt;span class="n"&gt;bridge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createPlayerView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;startSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pixelate&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kt"&gt;LoopingPlayerView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVLayerVideoGravity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&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;The &lt;code&gt;expect/actual&lt;/code&gt; interfaces did not change. What changed is the body of each &lt;code&gt;actual&lt;/code&gt;, which now calls a NativeBridge lambda instead of reaching into AVFoundation.&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;// FilmGenerator.ios.kt today, 37 lines in total&lt;/span&gt;
&lt;span class="kd"&gt;val&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;NativeBridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exportFilm&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;return&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="nf"&gt;exportFilm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expandedPaths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expandedStarts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trackPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bpm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="p"&gt;-&amp;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;success&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;A 108-line file became 37 lines, and no AVFoundation API name appears in it any more. Embedding into Compose works the same way: &lt;code&gt;UIKitView(factory = { NativeBridge.createPlayerView(...) })&lt;/code&gt;. The UI layer stays in Kotlin and does not change at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in Swift
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iosApp/iosApp/FilmExporter.swift&lt;/span&gt;
&lt;span class="c1"&gt;// give up after 60 seconds; this waits without blocking a thread&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;watchdog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nanoseconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;NSEC_PER_SEC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;withCheckedContinuation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exportAsynchronously&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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;span class="n"&gt;watchdog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structurally this is the same as the Kotlin version: a completion handler wrapped in a continuation. The difference is that here it is the usage the language and its runtime are designed around.&lt;/p&gt;

&lt;p&gt;The timeout is also straightforward to express with &lt;code&gt;Task&lt;/code&gt;. Because &lt;code&gt;Task.sleep&lt;/code&gt; waits without occupying a thread, everything else keeps running while it waits. The problem I had with &lt;code&gt;withTimeoutOrNull&lt;/code&gt; on the Kotlin side went away with it. The hang has not come back since the migration.&lt;/p&gt;

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

&lt;p&gt;Applying those questions, the iOS-specific work landed like this. The Android side is mostly shared code already, so the only side that needs sorting is iOS.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Written in&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Video composition and export&lt;/td&gt;
&lt;td&gt;Swift&lt;/td&gt;
&lt;td&gt;Calls back on completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio extraction (AVAssetReader + CoreMedia)&lt;/td&gt;
&lt;td&gt;Swift&lt;/td&gt;
&lt;td&gt;Track loading calls back on completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dominant colour extraction (CoreGraphics)&lt;/td&gt;
&lt;td&gt;Swift&lt;/td&gt;
&lt;td&gt;You release the objects yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video playback (AVPlayer + AVPlayerLayer)&lt;/td&gt;
&lt;td&gt;Swift&lt;/td&gt;
&lt;td&gt;The UI component itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sharing and saving to Photos&lt;/td&gt;
&lt;td&gt;Swift&lt;/td&gt;
&lt;td&gt;Calls back on completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The camera capture session&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Kotlin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Everything completes as you call it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row needs a little explanation.&lt;/p&gt;

&lt;p&gt;The camera capture session is still in Kotlin. &lt;code&gt;AVCaptureSession&lt;/code&gt;, &lt;code&gt;AVCaptureDeviceInput&lt;/code&gt; and &lt;code&gt;AVCaptureMovieFileOutput&lt;/code&gt; are all set up in &lt;code&gt;CaptureScreen.ios.kt&lt;/code&gt;.&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;setupSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginConfiguration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;// ... swap the input device and the outputs ...&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canSetSessionPreset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AVCaptureSessionPreset1920x1080&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSessionPreset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AVCaptureSessionPreset1920x1080&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commitConfiguration&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;I did not land on "move everything that touches AVFoundation to Swift" because this part is entirely synchronous. Everything from &lt;code&gt;beginConfiguration()&lt;/code&gt; to &lt;code&gt;commitConfiguration()&lt;/code&gt; completes as you call it, and there is no asynchronous completion to wait for.&lt;/p&gt;

&lt;p&gt;The recording-finished callback (&lt;code&gt;AVCaptureFileOutputRecordingDelegateProtocol&lt;/code&gt;) does call back, but it stays in Kotlin too. What makes a callback API dangerous is suspending a coroutine until the callback arrives. This one is not awaited that way; it only updates screen state when it fires, so it works fine in Kotlin.&lt;/p&gt;

&lt;p&gt;What I hand to Swift is only the &lt;code&gt;UIView&lt;/code&gt; that hosts the &lt;code&gt;AVCaptureVideoPreviewLayer&lt;/code&gt;, which is the UI component itself.&lt;/p&gt;

&lt;p&gt;The line is drawn per API behaviour, not per framework. Within the same AVFoundation, work that completes synchronously stays in Kotlin, and work that waits for a callback goes to Swift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share the right things, not the most lines
&lt;/h2&gt;

&lt;p&gt;After the migration, the Swift code came to 1,251 lines. commonMain is 6,049 lines, which puts the shared portion at 48.7%. Had I kept everything in Kotlin, that number would look better.&lt;/p&gt;

&lt;p&gt;Since then a larger refactor moved the screens and the generation flow into commonMain, and the shared portion is up to 64.6% (measured August 28, 2026). The line between Kotlin and Swift is still where this post leaves it; what got redrawn was the boundary inside Kotlin. I will write separately about what moved where.&lt;/p&gt;

&lt;p&gt;I would argue that the value of Kotlin Multiplatform is not in how many lines you shared, but in whether the things worth sharing are actually shared.&lt;/p&gt;

&lt;p&gt;In this app, the things worth sharing were the music engine and the UI, not the way a video export gets invoked. The engine is plain Kotlin in commonMain, about 2,200 lines, with no external dependencies. It runs on both platforms without a single platform branch, and the same input produces the same track on either one.&lt;/p&gt;

&lt;p&gt;It is also why someone with no iOS experience could ship the iOS version in two days. Most of it did not have to be written. The part that did was fastest to write in Swift, and gave me the least trouble.&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: coming soon...&lt;/li&gt;
&lt;/ul&gt;

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