← Blog
kotlin-multiplatformcompose-multiplatformandroidiosdeep-linking

End-to-End QR Deep Linking in a Kotlin Multiplatform App

April 8, 2026

How we wired QR code generation, custom URL schemes, and cross-platform deep link routing in BoxIndex — a KMP app built with Compose Multiplatform.

The Problem

BoxIndex is a home inventory app. You store items in containers, containers in locations. The core user loop is: open the app → navigate to a container → find your stuff.

But what if you could skip the navigation entirely? Stick a QR code on a box, scan it, and land directly on that container's screen. That's the goal.

The challenge: we're in a Kotlin Multiplatform codebase, sharing UI and logic between Android and iOS. Deep linking works differently on each platform — and we need a clean seam between them.

Step 1 — Choosing a URL Scheme

We went with a custom URI scheme:

boxindex://container/{identifier}

No http/https — this avoids the friction of Associated Domains / Universal Links and is sufficient for a focused use case like this.

The identifier is a UUID, not a database row ID. This matters as soon as you want a QR code to work across devices — a numeric ID is only meaningful on the device that generated it, while a UUID is stable through exports, imports, and reinstalls.

Step 2 — UUID Migration

The original schema used auto-increment INTEGER IDs. We added a migration:

-- 1.sqm
ALTER TABLE StorageContainer ADD COLUMN uuid TEXT NOT NULL DEFAULT '';

And on first app launch, we backfill any containers that were created before the migration:

suspend fun ensureContainerUuids() = withContext(Dispatchers.IO) {
    queries.getContainersWithoutUuid().executeAsList().forEach { container ->
        queries.updateContainerUuid(Uuid.random().toString(), container.id)
    }
}

Called in App.kt right after Koin initialises:

LaunchedEffect(Unit) {
    inventoryRepository.ensureContainerUuids()
}

All new containers generate their UUID at insert time:

queries.insertContainer(locationId, name, description, imagePath, Uuid.random().toString())

Step 3 — Generating the QR Code

We use qrose — a KMP-native QR code library that works in Compose without any platform-specific code:

val deepLink = if (containerUuid.isNotBlank())
    "boxindex://container/$containerUuid"
else
    "boxindex://container/$containerId" // fallback for old rows
 
val painter = rememberQrCodePainter(
    data = deepLink,
    shapes = QrShapes(
        ball = QrBallShape.roundCorners(0.25f),
        darkPixel = QrPixelShape.roundCorners(),
        frame = QrFrameShape.roundCorners(0.25f)
    )
)
 
Image(
    painter = painter,
    contentDescription = "QR Code for $containerName",
    modifier = Modifier.size(240.dp)
)

One rememberQrCodePainter call, works on both platforms. That's the payoff of KMP.

Step 4 — Android: Registering the Intent Filter

In AndroidManifest.xml, we register the scheme so Android routes matching URLs to our app:

<activity android:name=".MainActivity">
    <intent-filter android:autoVerify="false">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="boxindex" android:host="container" />
    </intent-filter>
</activity>

In MainActivity.onCreate(), we extract the identifier from the intent:

val deepLinkContainerRef: String? = intent?.data
    ?.takeIf { it.scheme == "boxindex" && it.host == "container" }
    ?.pathSegments?.firstOrNull()
 
setContent {
    App(deepLinkContainerRef = deepLinkContainerRef)
}

Step 5 — iOS: URL Scheme Registration

In Info.plist:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>boxindex</string>
        </array>
        <key>CFBundleURLName</key>
        <string>com.zarzara.boxindex</string>
    </dict>
</array>

In iOSApp.swift, SwiftUI's onOpenURL intercepts the URL and hands it to our shared Kotlin handler:

WindowGroup {
    ContentView()
        .onOpenURL { url in
            IosDeepLinkHandler.shared.handleUrl(urlString: url.absoluteString)
        }
}

Step 6 — The iOS Bridge: IosDeepLinkHandler

This is the interesting part. The deep link arrives in Swift, but all our routing logic lives in shared Kotlin. We need a bridge.

object IosDeepLinkHandler {
    private val _containerRef = MutableStateFlow<String?>(null)
    val containerRef: StateFlow<String?> = _containerRef
 
    fun handleUrl(urlString: String) {
        val regex = Regex("^boxindex://container/(.+)$")
        val ref = regex.find(urlString)?.groupValues?.get(1)?.takeIf { it.isNotBlank() }
        if (ref != null) _containerRef.value = ref
    }
 
    fun consume() {
        _containerRef.value = null
    }
}

StateFlow is the key here — it's observable from both Kotlin (Compose) and Swift (via collectAsState). The object singleton is exported to Swift as IosDeepLinkHandler.shared.

MainViewController.kt collects the flow and passes it down to App():

fun MainViewController() = ComposeUIViewController {
    val deepLinkRef by IosDeepLinkHandler.containerRef.collectAsState()
    App(deepLinkContainerRef = deepLinkRef)
}

Step 7 — Routing in Shared Code

App.kt receives deepLinkContainerRef: String? on both platforms. From there, it's pure shared Kotlin:

LaunchedEffect(deepLinkContainerRef) {
    if (deepLinkContainerRef != null) {
        deepLinkController.setContainerRef(deepLinkContainerRef)
    }
}

The controller resolves the ref — trying UUID first, falling back to numeric ID for backward compatibility — and pushes ContainerDetailScreen onto the Voyager navigator.

What Made This Work

A few decisions that saved pain:

  1. UUID over DB ID — QR codes survive exports, imports, and cross-device sharing.
  2. StateFlow as the iOS bridge — no callbacks, no platform channels, just reactive state that Compose already knows how to observe.
  3. Backward-compat fallbackif (path.toLongOrNull() != null) lookupById() else lookupByUuid() means old QR codes still work after the migration.
  4. qrose for generation — zero platform code for the QR rendering itself.

The Full Flow, End to End

User taps "QR Code" on a container
  → QrCodeScreen generates boxindex://container/{uuid}
  → qrose renders it as a Compose Image

User scans QR code on another device
  Android: intent-filter → MainActivity.onCreate() → App(deepLinkContainerRef)
  iOS:     onOpenURL → IosDeepLinkHandler.handleUrl() → StateFlow → MainViewController → App(deepLinkContainerRef)

App.kt receives deepLinkContainerRef
  → resolves UUID / ID → pushes ContainerDetailScreen

Both platforms, one routing implementation.

zarzara.app/apps/boxindex

← All postshello@zarzara.app