Banger
Blog Download

How We Use Kotlin Multiplatform to Ship Native Banger Apps Everywhere

6 min read · Published January 30, 2026

Kotlin Multiplatform is easy to describe badly.

The shallow version is: write once, run everywhere. That is not how serious apps work. Every platform has its own lifecycle, storage model, rendering quirks, background execution rules, and native APIs. Pretending those differences do not exist is how cross-platform apps start to feel cheap.

The way we use Kotlin Multiplatform in Banger is different:

share the product brain, then go native where the platform demands it.

That tradeoff has worked well for us. It lets us keep the hard product logic in one place while still building apps that respect Android, iOS, macOS, Windows, and Linux.

What we share

Banger has a lot of logic that should not be rewritten per platform:

  • mailbox state models
  • sync coordination
  • local database access
  • action processing
  • pending action projection
  • workspace and permission models
  • network clients
  • runtime interfaces
  • UI state and view models
  • categorization orchestration
  • backfill coordination

That code belongs in Kotlin. It is product logic, not platform chrome.

Kotlin Multiplatform lets us write that core once and compile it into the shapes each platform needs. Android can run it inside a normal activity. iOS can consume it through a framework. Desktop can run it on the JVM. macOS can host it from a native shell.

This keeps the product coherent. A thread should not behave one way on macOS and another way on Windows because two teams reimplemented the workflow rules differently.

What we do not try to hide

The shared core does not mean every target is identical.

Banger has platform-specific code for:

  • Android app startup and WorkManager scheduling
  • iOS hosting and runtime bootstrapping
  • macOS AppKit integration
  • desktop window chrome and tray behavior
  • platform-specific app data paths
  • secure storage and fallback key storage
  • attachment picking
  • clipboard behavior
  • local service discovery
  • HTML email rendering

Those are not failures of KMP. They are the point where a polished app has to respect the host operating system.

The boundary we try to keep is simple: shared product behavior belongs in common Kotlin; platform behavior belongs in platform source sets.

The app shells are intentionally different

On Android, Banger starts like a normal Compose app. The activity enables edge-to-edge rendering and calls the shared App() composable.

On iOS, Banger uses a ComposeUIViewController to host the shared UI from a native entry point.

On macOS, the shipped desktop app uses a native AppKit/SwiftUI shell with Kotlin Multiplatform underneath. That gives us native distribution, native window behavior, and better control over the experience.

On Windows and Linux, the desktop app runs through Compose Desktop on the JVM. That gives us a practical distribution target while keeping the same shared UI and runtime logic.

This split is less elegant on a diagram than “one app everywhere,” but it is much closer to what users actually need.

HTML email rendering forced a real Windows escape hatch

Email is full of HTML. Rendering that HTML safely and consistently inside a desktop app is harder than it sounds.

On desktop, our first path uses KCEF through the Compose webview stack. That works for some targets, but Windows needed a more native route. The right answer there is WebView2, because it is the browser runtime Windows users already expect apps to use.

So Banger chooses the HTML backend by platform:

  • Windows uses WebView2.
  • macOS and Linux use the KCEF path.

That sounds simple. It was not.

The WebView2 bridge

The Windows path uses a native C++ bridge that the Kotlin desktop app calls through JNA.

That bridge is responsible for the parts Kotlin and Compose Desktop should not have to fake:

  • loading the bundled WebView2Loader.dll
  • initializing the WebView2 environment
  • creating child host windows
  • managing WebView2 controllers
  • setting HTML content
  • synchronizing bounds with Compose layout
  • toggling visibility during resize
  • shutting down cleanly
  • reporting useful errors back to Kotlin

WebView2 also has COM threading requirements. The bridge uses an STA worker thread and message pumping so WebView2 initialization and controller operations happen on the right kind of Windows thread. Without that, the implementation becomes fragile quickly.

The Kotlin side keeps a composable API:

BangerHtmlWebView(html = messageHtml)

The platform-specific implementation decides whether that means KCEF or WebView2.

That is the kind of abstraction we want: simple at the product layer, honest at the platform layer.

Packaging the native bridge was its own problem

Once we had the bridge, we still had to ship it.

Windows builds need:

  • Visual Studio Build Tools with the C++ workload
  • CMake
  • the WebView2 SDK
  • the WebView2 runtime on the test machine
  • the generated bridge DLLs copied into desktop resources

The Gradle build now bootstraps much of this. It can locate Visual Studio installations, pick a CMake generator, download CMake when needed, build the bridge, and sync banger-webview2-bridge.dll plus WebView2Loader.dll into resources.

At runtime, the app extracts those DLLs into a process-scoped temp directory before loading them. That avoids relying on a global install path and makes the packaged app more predictable.

This is not glamorous work, but it is what turns a prototype into a desktop app people can run.

The resize problem

Native browser views embedded in desktop UI frameworks often expose one of the hardest cross-platform problems: geometry.

Compose knows where the WebView should be. WebView2 needs native window bounds. Window resizing can produce transient states where the browser view paints at the wrong size, flickers, or draws over the wrong area.

Banger tracks the composable’s window bounds and sends them into the bridge. During resize, the app can temporarily hide or cover the native host so the user does not see broken intermediate frames.

It is a small detail, but it is one of the differences between “it renders” and “it feels integrated.”

We also had build-system scars

Windows desktop builds exposed another practical issue: Kotlin compiler and Gradle daemon behavior can leave compile outputs locked. That is the kind of problem that has nothing to do with product design and everything to do with getting repeatable builds.

For stability, the project uses in-process Kotlin compiler execution in Gradle properties. It is not a headline feature, but it matters when the team is iterating on Windows packaging.

Cross-platform work is full of these small decisions. They are boring until they are the reason nobody can build the app.

macOS needed native hosting too

macOS has its own set of expectations. Window chrome, tray behavior, app lifecycle, pointer handling, text input, and AppKit integration all matter.

Banger’s macOS path includes native hosting work so Compose content can live inside an AppKit-controlled shell. That gives us more control over the shipped macOS experience than treating macOS as just another JVM desktop target.

Again, this is the same principle: share the product brain, but do not flatten the platform.

Why this architecture is worth it

The main benefit of Kotlin Multiplatform is not that it removes platform work. It is that it prevents product logic from fragmenting.

When we improve sync projection, mailbox state, categorization orchestration, or workflow behavior, that work can flow across targets. When a platform needs native treatment, we add an actual platform implementation instead of forking the whole product.

For Banger, that is the right compromise.

Email is too stateful and operational to maintain five separate product cores. But native apps are too platform-sensitive to pretend one runtime abstraction can hide everything.

Kotlin Multiplatform gives us the middle path: one shared system where it matters, and native engineering where users can feel the difference.

Written by