Engineering

commonMain cannot ask which Android version it is running on

A capability check belongs behind an expect, phrased as a capability.

MobileByteSensei 10 min read
kmpandroidarchitecture

TL;DR

  • A Build.VERSION.SDK_INT check in shared code, moved behind an expect that returned an Int. Three of five targets had nothing to return, so they returned 0 — and 0 >= 33 is false, so every capability gated on it was silently off.
  • Returning -1 instead was worse: -1 >= 33 is 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.

Thirty days of picker telemetry showing zero entries into the system-picker branch on iOS against eleven thousand four hundred and forty-seven attempts, so the feature was off rather than failing

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.

Every possible return value for a version number on a platform that has none, with zero and minus one both reading as too old, the maximum value entering a branch the platform cannot run, and null forcing a default at each call site

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.

Each candidate fix measured on the same thirty-day window, with the maximum-value sentinel converting a silent no-op into a crash and a string-keyed capability bag making a typo a silently false capability

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.


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".

Follow by RSS