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:
- UUID over DB ID — QR codes survive exports, imports, and cross-device sharing.
StateFlowas the iOS bridge — no callbacks, no platform channels, just reactive state that Compose already knows how to observe.- Backward-compat fallback —
if (path.toLongOrNull() != null) lookupById() else lookupByUuid()means old QR codes still work after the migration. - 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.