Testing runtime permissions in a mobile app: states, on-the-fly revocation, and edge cases
Permissions are one of the most underrated areas in mobile QA. They tend to get tested shallowly: “access granted — the camera works.” But the real bugs live in denials, on-the-fly revocations, and states people forget: “Don’t ask again” on Android, limited photo access on iOS, the process being killed after a permission change in Settings. Let’s go through the state model, when and how to ask, platform specifics, and a checklist that catches what usually slips through.
The state model: not two states, but at least five
The main mistake is assuming there are two states (granted / not granted). In reality every dangerous permission lives in several states, and each one is its own test case:
- Not determined — the system has never asked yet. This is the only state in which you can show the system dialog.
- Granted — access is available.
- Denied — on iOS the system dialog won’t show again, only a jump to Settings. On Android, after two denials the system sets “don’t ask” itself.
- Permanently denied / “Don’t ask again” (Android) — the system dialog no longer appears, the request silently “denies.” You have to lead the user to Settings manually.
- Limited / partial — iOS Photos (selected photos), approximate instead of precise location, “While Using” instead of “Always.”
- Provisional / ephemeral — silent pushes without a dialog (iOS), temporary access.
The full model and denial handling is in the Android request-permissions guide.
When and how to ask
- Not in a batch on launch. Ask when the benefit is obvious: tap “take a photo” → request the camera. Five dialogs on the first screen is an antipattern and a path to denials.
- Pre-permission priming. Your own explanation screen BEFORE the system dialog. On iOS the system dialog shows once: if the user denies it “cold,” there’s no second chance — only Settings. So first your “why we need this” screen, then the system one.
- Usage description strings (iOS).
NSCameraUsageDescription,NSLocationWhenInUseUsageDescription, etc. must be present and meaningful — without them iOS crashes the app the moment it requests.
Test cases: declining the priming screen doesn’t spend the system dialog (the permission stays not determined); accepting priming → the system dialog is shown.
Denial must not break the flow
Every denial needs a clear graceful fallback, not a dead end, a crash, or a blank screen:
- Camera denied → explanation + an “Open Settings” button or pick from the gallery.
- Location denied → manual city/address entry.
- Push denied → the app works, the feature is marked as off.
- Contacts / microphone / photos — same: the feature degrades but doesn’t block the rest.
Check separately: no infinite request loop (you can’t hammer the system dialog); a feature in the “denied” state doesn’t look available — the button must not lead nowhere.
On-the-fly revocation — the most forgotten case
On both platforms the user can revoke a permission in system Settings while the app is backgrounded. And here’s the key point people miss:
When a dangerous permission changes via Settings, the OS kills the app process. On return the app starts fresh (cold/warm start) — and the state must restore without a crash.
Scenario: grant the permission → use it → background → revoke in Settings → return to the app. Expectation: no crash, the feature correctly switches to “no access,” screen and data restored. Reverse scenario: revoke → grant again → return → the feature works again without a manual restart. On reset and process recreation, see the denial-handling section.
Special and “dangerous” permissions
- Background location. “Always” on iOS,
ACCESS_BACKGROUND_LOCATIONon Android 10+ — requested separately and usually after foreground access; stores review this strictly. See the Android location guide and CoreLocation authorization. - Precise vs approximate location. Android 12+ (
ACCESS_FINE/COARSE) and the precise toggle on iOS: the user may grant only approximate — the feature must work or degrade deliberately. See approximate location. - POST_NOTIFICATIONS (Android 13+). Push now also requires a runtime permission, like on iOS — old code doesn’t expect it.
- Special permissions (Android). Overlay, all-files-access, exact alarms, notification listener go through a separate system screen, not the usual dialog. See special permissions.
iOS specifics
- ATT (App Tracking Transparency). A separate prompt for the IDFA; shown once; on denial you can’t send the tracking identifier. See ATT and App Store privacy.
- Limited Photos. The user picked N photos — the app sees only those. It should offer “select more” and not crash on the rest. See enhanced privacy in Photos.
- Provisional notifications. Silent delivery without a dialog — check that notifications land in Notification Center.
Android specifics
- “Don’t ask again” / permanently denied. After the second denial
shouldShowRequestPermissionRationale→ false, the system dialog won’t appear again. The only path is Settings. Test: there must be no “dead” button that silently does nothing. - Auto-reset. If the app goes unused for months, Android resets permissions — on the next launch the state is
not determinedagain. - Battery / hibernation. Overlaps with OEM background restrictions — background location/push may not work even with the permission granted.
Privacy in practice: denial ≠ data doesn’t leave
A denial in the UI doesn’t guarantee data isn’t sent. You have to verify it on the wire: after declining ATT/location/analytics, the SDK genuinely shouldn’t send the identifier or coordinates.
- HTTPS intercept (Proxyman/Charles): granted → the field is in the request; denied → the field is absent or zeroed.
- Especially critical for ad and tracking SDKs and for compliance (GDPR/ATT).
- The consent flow (e.g. UMP in AdMob) must actually affect what goes over the network.
Tools
- Android:
adb shell pm grant/revoke <pkg> <perm>,adb shell pm reset-permissions,appopsfor special permissions. - iOS (simulator):
xcrun simctl privacy <device> grant|revoke|reset <service> <bundleId>. - Proxyman / Charles: confirming actual data transmission after a denial.
- Matrix run: each permission × each state × foreground / on-the-fly revocation.
Permission testing checklist
- Permission requested at a meaningful moment, not in a batch on launch
- A pre-permission priming screen exists; declining it doesn’t spend the single system dialog
- iOS: usage description strings present and meaningful (otherwise a crash on request)
- Each permission tested in 5 states: not determined / granted / denied / permanently denied / limited
- Denial yields a graceful fallback, not a crash, dead end, or dead button
- After permanently denied the system dialog isn’t shown again — lead to Settings
- On-the-fly revocation in Settings → process killed → return to the app without a crash, state restored
- Re-granting → the feature works again without a manual restart
- Precise/approximate location and While Using/Always are handled correctly
- Background location and special permissions (overlay, exact alarm, POST_NOTIFICATIONS) are separate scenarios
- iOS: ATT, limited Photos, provisional push verified separately
- Confirmed on the wire (Proxyman): after denial the SDK genuinely sends no data
Bottom line. Permissions aren’t “granted or not” — they’re a matrix of states × interruptions × platform specifics. The most expensive bugs: a crash on return after revocation, a dead button after permanently denied, and an SDK that sends data despite a denial. Run every permission through the checklist and you’ll close what otherwise only surfaces in store reviews.
Sources: Android — Request runtime permissions, Android — Approximate location, Apple — App Tracking Transparency, Apple — Requesting location authorization, Apple — Enhanced privacy in Photos.