commonMain cannot ask which Android version it is running on
A capability check belongs behind an expect, phrased as a capability.
TL;DR
- A
Build.VERSION.SDK_INTcheck in shared code, moved behind anexpectthat returned anInt. Three of five targets had nothing to return, so they returned 0 — and0 >= 33is false, so every capability gated on it was silently off. - Returning
-1instead was worse:-1 >= 33is also false, and now the number lies in a way that reads as an error code nobody checks. - The fix is to stop exporting a version at all. The question the code was asking was can I use the photo picker, and that question has an answer on every target.
- What reading on buys: the three capability shapes, why a boolean per capability beats one version number, and the measurement that showed the feature had been off on iOS since launch.
The case this post is about
A media picker, with an Android version gate that predated the shared module:
// androidMain, originally — correct, in context
fun pickPhoto() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(PickVisualMediaRequest()) // the system photo picker
} else {
launcher.launch(legacyStoragePermissionIntent()) // ask for READ_EXTERNAL_STORAGE
}
}
Moving the picker into shared code meant the gate had to move too, and the shape it took was the obvious one:
// commonMain
expect fun platformApiLevel(): Int
fun pickPhoto(picker: MediaPicker) {
if (platformApiLevel() >= 33) picker.systemPicker() else picker.legacyWithPermission()
}
// androidMain
actual fun platformApiLevel(): Int = Build.VERSION.SDK_INT
// iosMain, desktopMain, wasmJsMain
actual fun platformApiLevel(): Int = 0 // there is no API level here
That compiles on all five targets and the build is green.
The symptom
Not a crash — a feature that had never worked on three platforms, found by a support ticket about iOS:
"The photo picker asks me for permission every time and then shows an empty list."
legacyWithPermission() on iOS requested a photo-library permission and then read through a
legacy code path that had been written against Android's storage model. It returned nothing. On
desktop it opened a file dialog in the wrong directory. On the web it did nothing at all.
The measurement, from our own telemetry, once we knew what to count:
# picker invocations by branch, 30 days
systemPicker() legacyWithPermission() completed with a photo
android (33+) 40,536 0 39,114
android (<33) 0 2,841 2,602
ios 0 11,447 31
desktop 0 1,122 0
web 0 408 0
Thirty-one successful photo picks on iOS in a month, against 11,447 attempts. The feature had
been off since the shared module shipped, and it was off because 0 >= 33 is false.

Why the obvious fix failed
The first fix was a sentinel, on the theory that 0 was the problem:
actual fun platformApiLevel(): Int = -1 // "not applicable"
-1 >= 33 is also false. Every call site behaved identically, and now the value was a sentinel
nobody checked — so the next author reading platformApiLevel() would see a number, reasonably
assume it meant something, and compare it. A sentinel in an Int is only safe if every consumer
knows about it, and the type does not say so.
The second fix was Int?, which at least forces a decision:
expect fun platformApiLevel(): Int?
if ((platformApiLevel() ?: 0) >= 33) … // ← and here is the same bug, in one line
The null-coalesce is where it came back. Every call site had to pick a default for "there is no
API level", and the convenient default produces the original behaviour. Three of eleven call sites
wrote ?: 0; two wrote ?: Int.MAX_VALUE, which is the opposite bug and enabled a path iOS
cannot run.
The third attempt was to branch on platform instead:
if (platform == Platform.Android && platformApiLevel() >= 33) picker.systemPicker()
else if (platform == Platform.Ios) picker.iosPicker()
else picker.legacyWithPermission()
Which works and does not scale: every capability needs its own platform when, each one
enumerating five targets, and adding a sixth target means auditing every such branch to find the
ones that silently fall into an else meant for something else.
The actual mechanism
Build.VERSION.SDK_INT is not a fact about the device, it is a fact about one platform's
versioning scheme, and three of five targets have no such scheme.
Android has a monotonic integer that gates API availability. Comparing it is meaningful because the platform guarantees that a higher number is a superset.
iOS has a version string and a different model. UIDevice.systemVersion is "17.4" — not
ordered as an integer, not monotonic across major versions in the same way, and iOS availability
checks are a compile-time construct (@available) rather than a runtime comparison.
Desktop and the web have no version that bears on API availability at all. The JVM has its own version, a browser has a user agent, and neither answers "can I open a system photo picker".
So an expect fun platformApiLevel(): Int asks a question that is unanswerable on three targets,
and every possible return value is a lie:
0 reads as "very old" → the else branch, every time
-1 reads as an error code nobody checks → the else branch
Int.MAX_VALUE reads as "newest" → the if branch, on a platform that cannot run it
null forces a default at every call site, and the convenient default is 0
The question the code actually wanted was "can I use the system photo picker", and that one has a real answer everywhere — yes on Android 33+, yes on iOS via a different API, no on desktop, no on the web. A capability is answerable where a version number is not.

The fix
Express the capability rather than the version, one boolean per capability, with the platform-specific reasoning on the platform side.
// commonMain/kotlin/platform/Capabilities.kt
//
// One property per capability the app branches on. Deliberately NOT a version number and
// deliberately NOT a single `Capabilities` bag with a `has(name: String)` lookup: a string
// key is unchecked, so a typo is a silently-false capability, which is the same failure
// shape as `0 >= 33`.
expect class Capabilities {
// No constructor is declared here: each actual takes what its own answers need (the
// Android one needs a Context, the others need nothing) and the instance is supplied by
// the platform's own DI module rather than constructed from common code.
/** A system picker that needs no storage permission. */
val systemPhotoPicker: Boolean
/** Per-notification-channel settings the user controls. */
val notificationChannels: Boolean
/** Hardware-backed key storage the process cannot read. */
val hardwareKeystore: Boolean
}
// androidMain — the version check lives HERE, where the version means something
actual class Capabilities(private val ctx: Context) {
actual val systemPhotoPicker: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
actual val notificationChannels: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
actual val hardwareKeystore: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
ctx.packageManager.hasSystemFeature(FEATURE_STRONGBOX_KEYSTORE)
}
// iosMain — a different mechanism, the same answer shape
actual class Capabilities {
// PHPickerViewController has existed since iOS 14 and the deployment target is 15, so
// this is unconditionally true rather than a version comparison. Stating that as a
// constant with the reason is the point: the alternative is a version check that
// reimplements the deployment target the build already enforces.
actual val systemPhotoPicker: Boolean get() = true
actual val notificationChannels: Boolean get() = false // no channel equivalent
actual val hardwareKeystore: Boolean get() = true // Secure Enclave
}
// wasmJsMain — the honest answers
actual class Capabilities {
actual val systemPhotoPicker: Boolean get() = false // <input type=file> is not a picker
actual val notificationChannels: Boolean get() = false
actual val hardwareKeystore: Boolean get() = false // WebCrypto has no hardware guarantee
}
And the call site, which now has no number in it and no fallthrough:
// commonMain
fun pickPhoto(picker: MediaPicker, caps: Capabilities) =
if (caps.systemPhotoPicker) picker.systemPicker()
else picker.platformFallback()
The second branch is where the real change is, and it is a rename as much as a rewiring.
legacyWithPermission() named an Android storage-permission flow, so every platform that fell
into it ran Android's model badly. platformFallback() is an expect too: on Android below 33 it
is still the storage-permission path, on desktop it is the native file dialog, on the web it is
<input type="file">. Nobody runs another platform's fallback — iOS takes systemPicker()
outright, and the two targets that genuinely have no system picker get a path written for them.
And the output it produced, the same telemetry thirty days after:
$ ./tools/picker-report.sh --days 30
systemPicker() platformFallback() completed with a photo
android (33+) 42,006 0 40,118
android (<33) 0 2,744 2,511
ios 11,901 0 11,208
desktop 0 1,208 1,160 ← native dialog
web 0 412 388 ← input type=file
iOS went from 31 completed picks to 11,208.
How we proved it
That the feature had been off rather than broken — the distinction, because a broken feature gets debugged and an un-entered branch does not:
$ # branch counters, added before any fix
systemPhotoPicker branch entered, ios: 0 / 11,447
legacyWithPermission entered, ios: 11,447 / 11,447
Not a low success rate. Zero entries into the branch the feature lived in, for thirty days.
That every sentinel value fails. Tested rather than reasoned about, because each one is individually plausible:
actual returns ">= 33" path taken on ios completed
0 false legacy 31 / 11,447
-1 false legacy 29 / 11,392
Int.MAX_VALUE true systemPicker 0 — crashed: no Android picker on iOS
null, with ?: 0 false legacy 31 / 11,447
The MAX_VALUE row is the one worth keeping: it enters the right-looking branch and calls an API
that does not exist on the platform, so the sentinel that reads as "modern" converts a silent
no-op into a crash.
That a string-keyed capability bag reintroduces the bug. We considered caps.has("photoPicker")
and the fixture is a typo:
$ ./gradlew :platform:test --tests '*TypoIsSilentlyFalse*'
caps.has("photoPicker") → true
caps.has("photopicker") → false ← compiles, no warning, feature off
A property is checked by the compiler; a string key is not, and "silently false" is precisely the failure mode being fixed.
That the new call sites cannot fall through. The gate is exhaustiveness rather than a
test — removing a capability property makes every consumer a compile error, which is what the
when (platform) version could not offer:
# delete `systemPhotoPicker` from the expect class
$ ./gradlew :shared:compileKotlinMetadata
e: Unresolved reference: systemPhotoPicker × 4 call sites
What I cannot tell you is how many other version-shaped checks are in the corpus. We found
eleven call sites of platformApiLevel() and converted them; a grep for SDK_INT in shared code
now returns zero, and a capability expressed some other way — a try/catch around an API call, a
reflective presence check — would not be found by either search. The eleven are the ones that used
this helper.
And an honest cost. Capabilities has grown to nine properties and will keep growing, one per
branching decision. That is more code than one version number and it is the right amount: each
property is a question somebody actually asked, answered per platform by whoever knows that
platform. A version number is one line and answers the question wrongly three times.

What this generalizes to
A shared abstraction can only ask questions that have answers on every target, and a question borrowed from one platform's model usually does not. The useful move is to look past the mechanism to the decision it was feeding — a version comparison is never the goal, it is a proxy for can I do this thing — and to export the decision instead. The proxy forces every platform to produce a value in a vocabulary it does not have, and there is no safe value for a question that does not apply.
Related
- There is no shared permission API — the capability this one gates, where the lifecycles disagree as much as the availability.
- actual typealias looks like a shortcut — the other way an expect declaration publishes more than it meant to.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".