Engineering

There is no shared permission API, and the lifecycles disagree

Android can be asked twice; iOS once; the browser needs a gesture.

MobileByteSensei 11 min read
kmpplatformarchitecture

TL;DR

  • A shared requestPermission(Camera) that returned Boolean. On Android a false is retryable; on iOS a false is permanent; and on the web the call did nothing at all unless it happened inside a click handler.
  • Our retry-on-false logic was therefore correct on one platform and, on iOS, spent the single prompt the OS ever grants — measured: 42% of iOS users who declined once were in a state no code could recover.
  • The fix returns a status with a canAskAgain flag and separates querying from requesting, so the one-shot platforms are visible in the type rather than in a comment.
  • What reading on buys: the four lifecycles, the settings-deep-link that is the only recovery on two of them, and the measurement that showed a re-prompt loop burning prompts.

The case this post is about

A camera permission, shared:

// commonMain/kotlin/permission/Permissions.kt
expect class Permissions {
    suspend fun request(p: Permission): Boolean
}

enum class Permission { Camera, Microphone, Notifications, Location }
// commonMain — the call site, with what reads as sensible retry handling
suspend fun onScanTapped() {
    if (permissions.request(Permission.Camera)) {
        openScanner()
    } else {
        // "they said no — explain why we need it and ask again"
        _state.value = ShowCameraRationale
    }
}

suspend fun onRationaleAccepted() {
    if (permissions.request(Permission.Camera)) openScanner() else _state.value = CameraBlocked
}

Two prompts: one cold, one after a rationale. Which is a recommended Android pattern and is where the problem is.

The symptom

A funnel showing the scanner feature unusable for a large minority of iOS users:

# users who tapped Scan, 30 days
                 prompted   allowed   declined   reached the scanner later
  android          19,402    13,118     6,284          3,901  (62% of decliners recovered)
  ios              14,208     8,241     5,967              0  (0%)

Zero. Not a low recovery rate — none. Every iOS user who declined once was permanently unable to use the feature, and our rationale screen was asking them to tap a button that called request() again, which on iOS returns immediately with false and shows nothing.

So the user tapped "Allow camera access", nothing happened, and the app showed the same screen again.

And the web, separately:

# web users who tapped Scan
  prompted         0
  declined         0
  reached scanner  0

Nothing at all. The permission request was happening one await after the tap — our rationale flow — and navigator.mediaDevices.getUserMedia requires transient activation, which the await had already consumed.

Thirty days of camera-permission funnel data showing sixty-two percent of Android decliners later reaching the feature and zero of nearly six thousand iOS decliners doing so, because the second request shows no prompt

Why the obvious fix failed

The first fix was to stop asking twice on iOS:

if (platform == Platform.Ios) _state.value = CameraBlocked else _state.value = ShowCameraRationale

Which stops wasting the second prompt and leaves the user blocked with no route forward. "Camera access is off" with no action is accurate and useless — and the route forward exists: iOS lets you deep-link to the app's own Settings page.

The second fix was to show the rationale before the first prompt, which is the right pattern and not sufficient on its own:

_state.value = ShowCameraRationale          // explain first
// then, on accept, prompt once

Allow rates improved (measured below) and the decliners were still stuck, because a pre-prompt rationale does not help somebody who has already declined in a previous session. We needed both.

The third attempt was to detect the permanent state by remembering that we had asked:

if (prefs.getBoolean("asked_camera", false)) _state.value = CameraBlocked

Our own record of having asked, which is the same mistake as the notification flag in a sibling post: it records a historical event and not the current state. A user who declined, then granted in Settings, then returned, was shown CameraBlocked while holding the permission.

The actual mechanism

Four platforms, four lifecycles, and the differences are about repeatability rather than availability.

Android distinguishes "denied" from "denied permanently". A first denial is retryable; shouldShowRequestPermissionRationale returns true. After a second denial — or on newer versions after the user dismisses twice — it returns false and further requests resolve immediately without a prompt. So Android has three states: not asked, denied-retryable, denied-permanently.

iOS asks once per install, full stop. AVCaptureDevice.requestAccess shows the system prompt only when the status is notDetermined. Once it is denied, every subsequent call invokes the completion handler immediately with false and no UI. There is no retryable denial.

The browser ties the prompt to a user gesture and the decision to the origin. A denial is denied for the origin and the prompt cannot be re-shown by script. And the request must occur inside the gesture's call stack.

Desktop mostly has no prompt, and on macOS the camera prompt is shown by the OS at first use with its own one-shot semantics.

So Boolean collapses states that need different handling:

  returns false, and …
    may be asked again              → show a rationale, ask again        (android, first denial)
    may NOT be asked again          → deep-link to Settings              (android perm, ios, web)
    was never asked, no gesture     → ask again INSIDE a gesture         (web)
    is unavailable on this device   → hide the feature                   (no camera at all)

Four meanings for one value, and the correct response differs for each. Our code had one branch.

And the asymmetry that makes this expensive: on the platform where a prompt is retryable, spending one costs nothing. On the platform where it is one-shot, a defensive or looped request spends the only one the app will ever get — so a pattern that is harmless on Android is irreversible on iOS.

The four meanings a single false return collapses, each with a different correct response, against the three states Android distinguishes and the two that iOS and the browser have

The fix

A status type carrying canAskAgain, separate query and request operations, and a settings deep-link for the states that only Settings can resolve.

// commonMain/kotlin/permission/Permissions.kt
sealed interface PermissionStatus {
    data object Granted : PermissionStatus

    /** Never asked. A request WILL prompt — so ask at a moment the user understands. */
    data object NotDetermined : PermissionStatus

    /**
     * Refused. `canAskAgain` is the field the Boolean could not carry: on Android a first
     * denial is retryable, and on iOS and the web a denial is final. Without it, the caller
     * either wastes the one prompt it had or gives up on a platform that would have said yes.
     */
    data class Denied(val canAskAgain: Boolean) : PermissionStatus

    /** No such hardware, or the capability does not exist here. Hide the feature. */
    data object Unavailable : PermissionStatus
}

expect class Permissions {
    /** Never prompts. Safe before every use, and the only honest source of current state. */
    suspend fun status(p: Permission): PermissionStatus

    /**
     * MAY prompt, and on two platforms may do so only once ever. Call from a deliberate
     * moment with the rationale already shown — never defensively, never in a retry loop.
     */
    suspend fun request(p: Permission): PermissionStatus

    /** Opens the OS settings page for this app. The ONLY recovery from Denied(false). */
    suspend fun openSettings(): Boolean
}
// iosMain
actual suspend fun status(p: Permission): PermissionStatus = when (p) {
    Permission.Camera -> when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) {
        AVAuthorizationStatusAuthorized    -> PermissionStatus.Granted
        AVAuthorizationStatusNotDetermined -> PermissionStatus.NotDetermined
        // canAskAgain = false, always, on iOS. This single value is the whole post: it is
        // what tells the caller to offer Settings rather than another prompt.
        AVAuthorizationStatusDenied        -> PermissionStatus.Denied(canAskAgain = false)
        AVAuthorizationStatusRestricted    -> PermissionStatus.Unavailable
        else                               -> PermissionStatus.Unavailable
    }
    …
}

actual suspend fun request(p: Permission): PermissionStatus {
    // Guard the one-shot. A request when the status is already Denied shows nothing and
    // returns immediately, so a caller in a retry loop gets a tight loop of no-ops — which
    // is exactly what our rationale screen was doing.
    val current = status(p)
    if (current !is PermissionStatus.NotDetermined) return current

    return suspendCoroutine { cont ->
        AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { granted ->
            cont.resume(if (granted) PermissionStatus.Granted
                        else PermissionStatus.Denied(canAskAgain = false))
        }
    }
}
// androidMain — the only platform with a retryable denial
actual suspend fun status(p: Permission): PermissionStatus {
    val manifest = p.manifestName ?: return PermissionStatus.Unavailable
    if (ContextCompat.checkSelfPermission(ctx, manifest) == PERMISSION_GRANTED) {
        return PermissionStatus.Granted
    }
    val activity = host ?: return PermissionStatus.Denied(canAskAgain = true)   // assume retryable

    // shouldShowRequestPermissionRationale is true ONLY in the retryable state, and false both
    // before the first ask and after a permanent denial — so it cannot be read alone. The
    // "have we asked" flag disambiguates those two, and it is the one place a stored flag is
    // correct: it answers a question about our own history, not about current permission.
    val askedBefore = prefs.getBoolean("asked_$manifest", false)
    val rationale = ActivityCompat.shouldShowRequestPermissionRationale(activity, manifest)
    return when {
        !askedBefore -> PermissionStatus.NotDetermined
        rationale    -> PermissionStatus.Denied(canAskAgain = true)
        else         -> PermissionStatus.Denied(canAskAgain = false)
    }
}

And the call site, which now has four branches and a route out of every one:

// commonMain
suspend fun onScanTapped() {
    when (val s = permissions.status(Permission.Camera)) {
        PermissionStatus.Granted      -> openScanner()
        PermissionStatus.Unavailable  -> _state.value = ScannerUnsupported

        // Rationale BEFORE the prompt, because on two platforms this is the only prompt there
        // will ever be and an unexplained one is refused twice as often (measured below).
        PermissionStatus.NotDetermined -> _state.value = ShowCameraRationale

        is PermissionStatus.Denied -> _state.value =
            if (s.canAskAgain) ShowCameraRationale
            // The route the Boolean version did not have. "Off, and here is the switch"
            // rather than "off".
            else CameraBlockedOpenSettings
    }
}

/** Called from the rationale screen's accept button — inside the gesture, which the web needs. */
suspend fun onRationaleAccepted() {
    if (permissions.request(Permission.Camera) is PermissionStatus.Granted) openScanner()
    else _state.value = CameraBlockedOpenSettings
}

And the output it produced, 30 days after:

$ ./tools/permission-funnel.sh --days 30 --permission camera

                 prompted   allowed   declined   recovered via Settings   reached the scanner
  android          17,908    13,402     4,506            1,188                 14,590
  ios              13,884    10,021     3,863            1,702                 11,723
  web               4,102     3,218        884              n/a                  3,218
  desktop           1,204     1,141         63             n/a                  1,141

  iOS allow rate   58% → 72%        (rationale shown first)
  iOS recovery      0% → 44%        (Settings deep-link offered)

How we proved it

That iOS recovery was zero and not merely low — the measurement that made this a correctness problem rather than a conversion one:

  iOS users who declined once, then later reached the scanner:  0 of 5,967
  android equivalent:                                       3,901 of 6,284

Zero of nearly six thousand. A rate of 2% would have been a funnel problem; zero is a state no code path could leave.

That the second request showed nothing. Instrumented, because the user-visible symptom was "the button does nothing":

  iOS, status = denied, request() called
    system prompt shown      no
    completion invoked       yes, immediately (median 0.4 ms)
    returned                 false

A tight no-op. Our rationale screen called it on every tap, so a user could tap "Allow" ten times and see nothing each time.

That the pre-prompt rationale is worth it on its own. Separated from the Settings link, because we shipped both and wanted to know which did what:

                                      iOS allow rate   iOS recovery
  as shipped (prompt cold, retry)          58%              0%
  + rationale before the prompt            72%              0%
  + Settings deep-link                     72%             44%

Fourteen points from asking better, forty-four from offering a way back. Neither substitutes for the other.

That our stored flag was the wrong authority — except for one question. The distinction took a measurement to see:

  question                                   our flag    the system
  "is the permission granted right now?"      wrong       right
  "have we ever shown this prompt?"           right       cannot answer

The second row is why the Android implementation keeps a flag: shouldShowRequestPermissionRationale returns false both before the first ask and after a permanent denial, and the system has no API to distinguish them. Our own history is the only thing that can.

That the web needed the request inside the gesture. The timing, which is the same mechanism as the share sheet:

  getUserMedia called …                     prompt shown
  directly in the click handler                   yes
  after `delay(0)`                                yes
  after one suspending call (our rationale)        no

Which is why onRationaleAccepted is wired to the button rather than to a state change.

That request() guarding on status is load-bearing. The fixture is a caller in a retry loop, which is what we had:

$ ./gradlew :core:permission:iosSimulatorArm64Test --tests '*RequestDoesNotBurnThePrompt*'
  without the status guard:  10 calls, 1 prompt, 9 silent no-ops → caller loops forever
  with it:                   10 calls, 1 prompt, 9 return Denied(false) → caller routes to Settings

What I cannot tell you is the Android permanent-denial threshold across versions. Two dismissals is the documented behaviour on recent versions and older ones differ, and some OEM builds appear to differ again — so canAskAgain on Android is derived from an API whose own semantics I have only verified on two device families.

And an honest gap. openSettings() takes the user out of the app to a system screen, and we cannot observe whether they changed anything — so on return the app re-queries status, which is correct, and there is no way to show a useful message if they came back without changing it. The 44% recovery figure is users who went and actually toggled it; the remainder went, did nothing, and returned to the same screen.

Three measurements separating the rationale from the settings link, showing fourteen points of allow rate from asking better and forty-four points of recovery from offering a route back

What this generalizes to

A shared API over a per-platform capability has to carry the capability's lifecycle, not just its current value — and the lifecycle is where platforms differ most. A Boolean is the wrong return type for anything whose "no" means something different per platform, because the caller's correct response differs and a single branch will be right on exactly one target. The sharpest version of the rule: where an operation is irreversible on any platform, the API must separate asking from checking, or careful-looking code will spend the irreversible thing defensively.


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