mobilegame-qapermissionsiosandroid

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_LOCATION on 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 determined again.
  • 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, appops for 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.