There is no shared permission API, and the lifecycles disagree
Android can be asked twice; iOS once; the browser needs a gesture.
TL;DR
- A shared
requestPermission(Camera)that returnedBoolean. On Android afalseis retryable; on iOS afalseis 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
canAskAgainflag 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.

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

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.
Related
- The biometric prompt belongs to the OS — the same system-presented UI, with a host that can vanish mid-call.
- One notification API, four delivery systems — the permission half of that post, and the 31% who had silently revoked.
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".