# Automate Mobile Permissions Without Brittle System UI

> Tapping the camera prompt in every test couples a suite to OS copy and to whatever earlier runs left behind. This guide maps the states each platform actually exposes, including Android's USER_SET, USER_FIXED and policy-locked denials and iOS's restricted status, and moves the state-to-screen decision into host tests. It seeds Android with pm grant, revoke and set-permission-flags (with a read-back, because pm can exit 0 without changing anything), resets the iOS camera with resetAuthorizationStatus, injects the remaining iOS states through a debug-only seam, and keeps exactly one real-prompt receipt per platform.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-17T13:30:00.000Z

- Updated: 2026-10-14T10:26:51.547Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/mobile-permission-testing-system-ui

- Tags: mobile-testing, permissions, android, ios, xcuitest, appium, test-isolation

**Quick answer:** Treat a permission as state you set before the app starts, not a dialog every test taps. Name the states the app can actually observe (on Android: never asked, granted, denied once, blocked by the user, locked by policy; on iOS: notDetermined, authorized, denied, restricted) and give each one a screen. Seed Android with `pm grant`, `pm revoke` and `pm set-permission-flags`, then read the state back, because `pm` can exit 0 without changing anything. On iOS, reset the camera with `resetAuthorizationStatus(for:)` and seed the other states through a debug-only seam. Assert the app's recovery UI in every state. Keep exactly one test per platform that taps the real system prompt, and run it before release.

A scanner app's UI suite has a test that taps the scan button and expects the camera prompt. On a fresh emulator it passes. In CI it waits ten seconds for an Allow button and fails. Earlier in the same run, a denial test tapped Deny. A previous run on the same emulator tapped Deny too, and since Android 11 a second denial means the system stops showing the dialog for that app. On the iOS side the prompt does appear, but the locator was written against button copy that belongs to the operating system, and that copy is not yours to pin.

Neither failure says anything about the app. What would say something, what the app shows a user who refused the camera twice, is what most suites never reach, because getting there by tapping takes two app runs and a device nobody else touched. This part of the series turns permission state into test setup. The [first part](/blog/automation-tutorials/mobile-test-strategy-fidelity-cost) left the system prompt itself for this article; it is still here, confined to one test per platform.

**Versions checked on 2026-10-14.** Appium 3.8.0 with the UiAutomator2 driver 8.7.0 and the XCUITest driver 12.18.0 (npm registry). AndroidX Test `rules` 1.7.0 and UI Automator 2.4.0 (Google Maven). Xcode 27 (27A266a) is the current release, with 27.1 at release candidate; iOS is at 27.0.1. Android permission behaviour is quoted from developer.android.com pages last updated 2026-10-01 and from AOSP source on the `main` branch. The Swift mapping below was compiled and run with Swift 6.4 from the macOS command-line tools; the Android and XCUITest snippets were not executed on a device for this article.

## What the app can observe, platform by platform

Start from what the app can read. The two platforms disagree on how many states exist, so a matrix copied from one onto the other contains states that cannot happen.

| What happened | Android: what the app reads | iOS: what the app reads | Screen to assert |
| --- | --- | --- | --- |
| Never asked | Denied; rationale `false`; no user flags | `.notDetermined` | Explain, then request |
| Allowed | Granted | `.authorized` | Viewfinder |
| Denied once | Denied; rationale `true` (`USER_SET`) | `.denied` | Android: rationale, then ask again. iOS: settings link |
| Denied twice | Denied; rationale `false` (`USER_FIXED`) | No separate state | Settings link |
| Not the user's decision | Denied; rationale `false` (`POLICY_FIXED`) | `.restricted` | Explain the limitation |

The Android column comes from the platform source rather than the reference page, which only says the method returns "whether you should show permission rationale UI." In AOSP, `shouldShowRequestPermissionRationale` returns `false` if any of `USER_FIXED`, `POLICY_FIXED` or `SYSTEM_FIXED` is set, and otherwise returns whether `USER_SET` is. So a permission the app never requested and a permission the user blocked give the app the same two answers: denied, no rationale. The platform does not tell them apart for you. The app has to remember that it asked.

Android's permission guide names both flags: a permission denied once is flagged `USER_SET`, and one "denied permanently by selecting Deny twice" is flagged `USER_FIXED`. The policy row exists because a device owner or profile owner can call `DevicePolicyManager.setPermissionGrantState` to deny a permission so that "the user cannot manage it through the UI." To your app that looks like a user block, but sending the user to Settings will not help them.

iOS has fewer states and one hard edge. After the first answer, "the system saves the user's selection so that it doesn't have to prompt the user again," so there is no denied-but-askable state to test. `.restricted` is the case where "a user can't change the authorization status, possibly due to the system imposing restrictions like parental controls." A Settings button on that screen leads nowhere, which makes it a bug a test can catch.

On Android, "granted" is not always permanent. Since Android 11, camera, microphone and location prompts offer *Only this time*. The app keeps access while it is visible and for a short period after it goes to the background, and the user is asked again next time.

One constraint shapes the settings screen. Android's own sample code, in the branch that handles a denial, says: "Don't link to system settings in an effort to convince the user to change their decision." The settings link therefore belongs on the screen a user reaches by tapping the blocked feature again, not on launch. That is assertable: in the blocked state, the link should be absent until the user taps scan.

## Put the state-to-screen decision in plain code

Every row of that table is a branch in the app. Pull the branch out into a function that takes the platform's answer as input, and every row becomes a host test that runs in milliseconds. On iOS the input is the authorization status, behind a protocol so a test can supply it:

```
import AVFoundation

public enum CameraScreen: Equatable, Sendable {
    case capture            // authorized
    case explainThenAsk     // notDetermined: in-app explainer, then requestAccess
    case openSettings       // denied: the system will not prompt again
    case explainLimitation  // restricted: the user cannot change it either
}

public protocol CameraAuthorizing: Sendable {
    func status() -> AVAuthorizationStatus
}

public struct SystemCamera: CameraAuthorizing {
    public init() {}
    public func status() -> AVAuthorizationStatus {
        AVCaptureDevice.authorizationStatus(for: .video)
    }
}

public func cameraScreen(_ camera: some CameraAuthorizing) -> CameraScreen {
    switch camera.status() {
    case .authorized: return .capture
    case .notDetermined: return .explainThenAsk
    case .denied: return .openSettings
    case .restricted: return .explainLimitation
    @unknown default: return .explainLimitation
    }
}
```

```
import AVFoundation
import Testing

struct FixedCamera: CameraAuthorizing {
    let value: AVAuthorizationStatus
    func status() -> AVAuthorizationStatus { value }
}

@Test(arguments: [
    (AVAuthorizationStatus.authorized, CameraScreen.capture),
    (.notDetermined, .explainThenAsk),
    (.denied, .openSettings),
    (.restricted, .explainLimitation),
])
func screenFollowsAuthorization(status: AVAuthorizationStatus, expected: CameraScreen) {
    #expect(cameraScreen(FixedCamera(value: status)) == expected)
}
```

`AVAuthorizationStatus` exists on macOS from 10.14, so this mapping runs on a Mac with no simulator. Driven through a plain executable with the same four cases, it printed each raw value, the screen and a check:

```
3 capture ok
0 explainThenAsk ok
2 openSettings ok
1 explainLimitation ok
```

On Android the mistake is in the branch order. This version reads naturally and ships a dead button:

```
// Wrong: treats "no rationale" as "safe to ask".
fun onScanTapped() {
    when {
        hasCameraPermission() -> openViewfinder()
        shouldShowRequestPermissionRationale(CAMERA) -> showRationale()
        else -> requestCamera.launch(CAMERA) // blocked: no dialog, callback gets false
    }
}
```

In the blocked state the user taps scan, the system shows nothing, the callback reports a denial, and the screen does not change. The fix is the remembered flag from the previous section, written when the app launches the request:

```
enum class CameraScreen { VIEWFINDER, EXPLAIN_THEN_ASK, RATIONALE, OPEN_SETTINGS }

fun cameraScreen(granted: Boolean, rationale: Boolean, askedBefore: Boolean): CameraScreen =
    when {
        granted -> CameraScreen.VIEWFINDER
        rationale -> CameraScreen.RATIONALE            // USER_SET, nothing fixed
        !askedBefore -> CameraScreen.EXPLAIN_THEN_ASK  // no flags yet
        else -> CameraScreen.OPEN_SETTINGS             // USER_FIXED or POLICY_FIXED
    }

class CameraScreenTest {
    @Test
    fun blockedIsNotNeverAsked() {
        assertEquals(CameraScreen.EXPLAIN_THEN_ASK, cameraScreen(false, false, askedBefore = false))
        assertEquals(CameraScreen.OPEN_SETTINGS, cameraScreen(false, false, askedBefore = true))
    }
}
```

A user block and a policy lock both land on `OPEN_SETTINGS`, so write that screen's copy to hold when Settings will not let the user change anything either. The cost of this design is one persisted boolean whose value must agree with the platform's flags. Seeding has to set both, or the test runs a state no user can reach.

## Seed Android grants and flags together

Device tests still need real platform state, because the instrumented build calls the real `checkSelfPermission`. The shell's package manager reaches every user-controlled row in the table. `grant` and `revoke` are documented on the adb page, and the guide documents `clear-permission-flags`. `set-permission-flags` appears in the `pm` help text in AOSP, which limits the flags it accepts to `review-required`, `revoked-compat`, `revoke-when-requested`, `user-fixed` and `user-set`.

```
#!/usr/bin/env bash
# Seed one camera permission state before the app starts.
# Usage: seed-camera.sh granted|not-asked|denied-once|blocked
set -euo pipefail
PKG=com.example.scanner
PERM=android.permission.CAMERA
STATE=${1:?state required}

adb shell am force-stop "$PKG"           # revoking kills the process anyway

case "$STATE" in
  granted)
    adb shell pm grant "$PKG" "$PERM" ;;
  not-asked)
    adb shell pm revoke "$PKG" "$PERM"
    adb shell pm clear-permission-flags "$PKG" "$PERM" user-set user-fixed ;;
  denied-once)
    adb shell pm revoke "$PKG" "$PERM"
    adb shell pm clear-permission-flags "$PKG" "$PERM" user-fixed
    adb shell pm set-permission-flags "$PKG" "$PERM" user-set ;;
  blocked)
    adb shell pm revoke "$PKG" "$PERM"
    adb shell pm set-permission-flags "$PKG" "$PERM" user-set user-fixed ;;
  *)
    echo "unknown state: $STATE" >&2; exit 2 ;;
esac

# pm can exit 0 without changing anything, so read the state back.
dump=$(adb shell dumpsys package "$PKG")
line=$(grep -m1 "$PERM: granted=" <<<"$dump")
echo "$line"
has() { grep -q "$1" <<<"$line"; }
case "$STATE" in
  granted)     has 'granted=true' ;;
  not-asked)   has 'granted=false' && ! has 'USER_SET' && ! has 'USER_FIXED' ;;
  denied-once) has 'granted=false' && has 'USER_SET' && ! has 'USER_FIXED' ;;
  blocked)     has 'granted=false' && has 'USER_FIXED' ;;
esac || { echo "seed did not take: wanted $STATE" >&2; exit 1; }
```

The read-back is there for a reason found in the source. When a permission is `POLICY_FIXED`, the grant path logs "Cannot grant policy fixed permission" and returns, and the shell command still exits 0. On a managed test device, a "granted" seed can silently stay denied. The read-back logic was exercised against a stubbed `adb` printing canned `dumpsys` blocks: it accepted each correct state and exited 1 for a blocked seed missing `USER_FIXED` and for a grant that a policy lock had ignored.

Four other details decide whether this setup stays honest:

- Seed before launch. Android's guide says that if the user revokes a permission, "your app's process terminates", and a shell revoke has the same effect on a test that is mid-flow. The script stops the app first so nothing depends on timing.

- Seed the app's own memory to match. For `denied-once` and `blocked`, launch with a debug-only extra such as `adb shell am start -n com.example.scanner/.ScanActivity --ez seed_camera_asked true`, so the remembered flag agrees with the platform flags.

- `pm reset-permissions` takes no package argument. Its help text reads "Revert all runtime permissions to their default state", and the implementation resets runtime permissions across the device. On a shared device it also resets every other app's grants.

- `adb install -g` grants every manifest permission at install. That suits suites that are not about permissions, but under it the never-asked path never runs.

The AndroidX rule many suites start with has a sharper edge. `GrantPermissionRule`'s reference says a granted permission "will apply for all tests running in the current Instrumentation", that "there is no way of revoking a permission after it was granted", and that trying "will crash the Instrumentation process." One test class using it can make every later denial test in the same run test the granted state instead:

```
// Wrong: grants CAMERA for the whole instrumentation run, not this class.
@get:Rule
val camera: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA)

// Right for permission-state tests: no rule. The runner seeds the state
// with seed-camera.sh before launching this class, one state per invocation.
@Test
fun blockedCameraShowsSettingsLinkOnlyAfterTap() {
    launchActivity<ScanActivity>().use {
        onView(withId(R.id.open_settings)).check(doesNotExist())
        onView(withId(R.id.scan_start)).perform(click())
        onView(withId(R.id.open_settings)).check(matches(isDisplayed()))
    }
}
```

If the suite runs through Appium, the UiAutomator2 driver's `mobile: changePermissions` covers grant and revoke for the `pm` target, and `mobile: getPermissions` reads back granted or denied lists. Neither touches flags, so the denied-once and blocked rows need `mobile: shell`, which the driver enables only with the `uiautomator2:adb_shell` server feature. That belongs in the reviewed server config described in the [Appium extensions part](/blog/automation-tutorials/appium-drivers-plugins-test-boundaries) of this series. This helper type-checks against WebdriverIO 10.0.2 with TypeScript 7.0.2 under `--strict`:

```
import type { Browser } from 'webdriverio';

type CameraState = 'granted' | 'not-asked' | 'denied-once' | 'blocked';

const PKG = 'com.example.scanner';
const CAMERA = 'android.permission.CAMERA';
const FLAGS: Record<Exclude<CameraState, 'granted'>, string[]> = {
  'not-asked': [],
  'denied-once': ['user-set'],
  blocked: ['user-set', 'user-fixed'],
};

const pm = (driver: Browser, ...args: string[]) =>
  driver.execute('mobile: shell', { command: 'pm', args });

export async function seedCamera(driver: Browser, state: CameraState): Promise<void> {
  // Revoking a runtime permission kills the process, so stop it first and seed cold.
  await driver.execute('mobile: terminateApp', { appId: PKG });

  if (state === 'granted') {
    await driver.execute('mobile: changePermissions', {
      permissions: CAMERA, appPackage: PKG, action: 'grant',
    });
  } else {
    await driver.execute('mobile: changePermissions', {
      permissions: CAMERA, appPackage: PKG, action: 'revoke',
    });
    // changePermissions cannot touch flags; this needs uiautomator2:adb_shell.
    await pm(driver, 'clear-permission-flags', PKG, CAMERA, 'user-set', 'user-fixed');
    const flags = FLAGS[state];
    if (flags.length > 0) {
      await pm(driver, 'set-permission-flags', PKG, CAMERA, ...flags);
    }
  }

  const granted = (await driver.execute('mobile: getPermissions', {
    type: 'granted', appPackage: PKG,
  })) as string[];
  if (granted.includes(CAMERA) !== (state === 'granted')) {
    throw new Error(`camera seed failed: wanted ${state}, granted=${granted.includes(CAMERA)}`);
  }

  await driver.execute('mobile: activateApp', { appId: PKG });
}
```

The policy row cannot be seeded this way at all: `policy-fixed` is not in the flag list `pm` accepts, and setting it takes a device or profile owner. Cover it with the host test, and on a managed device only if enterprise customers make it matter.

## iOS: reset the camera, inject the rest

Simulators have had `xcrun simctl privacy <device> grant|revoke|reset <service> <bundle id>` since Xcode 11.4, whose release notes present it as a way to "create known states for testing purposes." The service list, as documented by Appium's simctl wrapper and the XCUITest driver, covers calendar, contacts, location, photos, media library, microphone, motion, reminders and Siri. It has no camera entry. Run `xcrun simctl help privacy` on your own Xcode before relying on that, since the list belongs to the tool version.

```
# Works for the services simctl knows; camera is not one of them.
xcrun simctl privacy booted grant microphone com.example.scanner
xcrun simctl privacy booted reset all com.example.scanner
```

For the camera, the documented switch is `XCUIApplication.resetAuthorizationStatus(for:)` with `XCUIProtectedResource.camera`, available from iOS 13.4. It returns the app to `.notDetermined`, and the XCUITest driver's `mobile: resetPermission`, which takes a protected-resource name, is documented for simulators and real devices. Nothing in XCTest sets camera access to authorized, denied or restricted. Those states come from tapping the prompt or from device restrictions, so UI tests reach them through the protocol from the previous section, chosen by a launch argument in debug builds only:

```
// App target
#if DEBUG
struct SeededCamera: CameraAuthorizing {
    let value: AVAuthorizationStatus
    func status() -> AVAuthorizationStatus { value }
}

func makeCameraAuthorizer(
    arguments: [String] = ProcessInfo.processInfo.arguments
) -> any CameraAuthorizing {
    guard let flag = arguments.firstIndex(of: "-seedCamera"),
          arguments.indices.contains(flag + 1) else { return SystemCamera() }
    switch arguments[flag + 1] {
    case "authorized": return SeededCamera(value: .authorized)
    case "denied": return SeededCamera(value: .denied)
    case "restricted": return SeededCamera(value: .restricted)
    default: return SystemCamera()   // "notDetermined" uses the real API after a reset
    }
}
#else
func makeCameraAuthorizer() -> any CameraAuthorizing { SystemCamera() }
#endif
```

```
// UI test target
import XCTest

final class CameraStateScreenTests: XCTestCase {
    private func launch(seeding state: String) -> XCUIApplication {
        let app = XCUIApplication()
        app.launchArguments += ["-seedCamera", state]
        app.launch()
        return app
    }

    func testDeniedOffersSettingsAfterTap() {
        let app = launch(seeding: "denied")
        XCTAssertFalse(app.buttons["camera.openSettings"].exists)
        app.buttons["scan.start"].tap()
        XCTAssertTrue(app.buttons["camera.openSettings"].waitForExistence(timeout: 5))
    }

    func testRestrictedExplainsWithoutSettingsLink() {
        let app = launch(seeding: "restricted")
        app.buttons["scan.start"].tap()
        XCTAssertTrue(app.staticTexts["camera.restrictedExplanation"].waitForExistence(timeout: 5))
        XCTAssertFalse(app.buttons["camera.openSettings"].exists)
    }
}
```

The seeding factory compiled with `-D DEBUG` and returned the expected screen for each launch argument; the UI test needs Xcode and a simulator, and was not run here. Since Xcode 16.3 the UI classes live in XCUIAutomation, and `import XCTest` re-exports it, so the test file needs no extra import.

The trade is a test seam in shipping code, kept out of release builds by `#if DEBUG`. It also limits what these tests prove: they show your UI given a status, not that iOS reports that status. For the settings link itself, `UIApplication.openSettingsURLString` is the documented deep link to your app's page in Settings. Assert that the button opens it, and leave what Settings shows to the OS.

## One real prompt per platform

Something still has to prove that the real prompt appears, that it carries your purpose string, and that the app reacts to the answer. On iOS the purpose string is the one piece of that dialog you own. Apple requires `NSCameraUsageDescription`, and calling `requestAccess` or starting a capture session without it "raises an exception." That makes it worth one test, started from a reset state:

```
final class CameraPromptReceiptTests: XCTestCase {
    // OS-owned copy. The only place the suite depends on it.
    private let allowLabel = "Allow"

    func testFirstScanShowsSystemPromptWithOurPurpose() {
        let app = XCUIApplication()
        app.resetAuthorizationStatus(for: .camera)
        app.launch()
        app.buttons["scan.start"].tap()
        app.buttons["camera.explainer.continue"].tap()

        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let alert = springboard.alerts.firstMatch
        XCTAssertTrue(alert.waitForExistence(timeout: 10))
        let purpose = alert.staticTexts.matching(
            NSPredicate(format: "label CONTAINS %@", "scan shelf labels")).firstMatch
        XCTAssertTrue(purpose.exists, "purpose string missing from the prompt")

        alert.buttons[allowLabel].tap()
        XCTAssertTrue(app.otherElements["camera.viewfinder"].waitForExistence(timeout: 5))
    }
}
```

The test queries the alert directly rather than registering an interruption monitor. Apple's guidance is explicit: "When an alert or other modal UI is an expected part of the test workflow, don't write a UI interruption monitor," because monitors only fire when an unrelated interruption blocks an element. Reaching the alert through SpringBoard's bundle identifier is common practice, but Apple's documentation for `init(bundleIdentifier:)` does not promise which process hosts system alerts. That uncertainty is another reason to keep it to one test.

On Android, the dialog's buttons are defined in the PermissionController module's layout in AOSP, with ids such as `permission_allow_button`, `permission_allow_foreground_only_button`, `permission_allow_one_time_button` and `permission_deny_button`. Matching on the id suffix with a pattern avoids depending on button copy and on which package hosts the dialog on a given build:

```
@Test
fun firstScanShowsSystemDialog() {
    // Runner seeded "not-asked" before this test.
    val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    launchActivity<ScanActivity>().use {
        onView(withId(R.id.scan_start)).perform(click())
        onView(withId(R.id.explainer_continue)).perform(click())

        val allow = device.wait(
            Until.findObject(By.res(Pattern.compile(".*:id/permission_allow(_foreground_only)?_button"))),
            10_000,
        )
        assertNotNull("system camera dialog did not appear", allow)
        allow.click()
        onView(withId(R.id.viewfinder)).check(matches(isDisplayed()))
    }
}
```

Those ids come from AOSP; a vendor build can change them, which is exactly why this check runs on the release devices from the [device matrix](/blog/automation-tutorials/mobile-device-matrix-real-risk) before a release, not on every pull request. The receipts cover the joint between your app and the OS; everything else covers behaviour.

## Failures that come from leftover state

**The Android dialog never appears.** The cause is usually a `USER_FIXED` flag left by an earlier test or run, which turns the request into an immediate denial. The check: `adb shell dumpsys package com.example.scanner` and read the camera line's flags. The fix is the seed script at the start of every test that cares, never a reinstall in the middle of a run.

**The app vanishes right after setup.** A revoke ran while the app was alive, and the process was terminated as the guide describes. It shows as a test that fails on its first interaction with no crash report. The check is whether the seed step ran after launch. The fix is ordering: stop, seed, launch.

**A granted permission disappears after the app is backgrounded.** A receipt test, or a person at the device, picked *Only this time*, and the grant expired once the app left the foreground. Android's flag names include `ONE_TIME` alongside `USER_SET` and `USER_FIXED`, so the same `dumpsys` line tells this case apart from the first. Seed `granted` with `pm grant` rather than inheriting whatever the receipt left.

**The iOS receipt waits for an alert that never comes.** A previous run on the same simulator already answered, and the status is no longer `.notDetermined`. The check is whether `resetAuthorizationStatus(for: .camera)` runs before `launch()`. Without the reset, the test passes only on fresh simulators.

## Seed the blocked state today

Pick the one permission your most-used feature depends on. On Android, run the seed script with `blocked`, launch, tap the feature, and check that the screen offers a way forward rather than a button that does nothing. On iOS, launch with `-seedCamera denied` and then `restricted`, and check that only the first offers Settings. If either check fails on the shipped build, that is a production bug, found without touching a system dialog.

Keep three pieces of evidence: the `dumpsys` line the seed script printed, a screenshot of the recovery screen for each state, and one passing receipt per platform from the release device run. Together they show the app handles every reachable state and that the real prompt still connects to it.

## Questions about permission automation

### Why not set autoGrantPermissions or autoAcceptAlerts and move on?

For suites whose subject is not permissions, do. UiAutomator2's `appium:autoGrantPermissions` grants every requested permission when the session starts, and the XCUITest driver's `appium:autoAcceptAlerts` accepts every alert, privacy prompts included. Both remove the denial paths from that suite entirely, so they cannot be the only place permissions are tested.

### Does the camera matrix carry over to photos?

Not unchanged. Photo access has a fifth state on both platforms: `PHAuthorizationStatus.limited` on iOS 14 and later, and partial access through `READ_MEDIA_VISUAL_USER_SELECTED` on Android 14 and later, where the user picks individual items. Add a row for it, with its own screen and its own way to reselect.

### Can an AI assistant write the state matrix?

It can enumerate combinations quickly, and it will also produce rows the platform cannot reach, such as an iOS camera that was denied but can still be asked. Which states exist is decided by the platform documentation, the AOSP rationale logic and a real device. Use generated rows as a checklist, then delete every row you cannot seed or reach.

## Primary references

- [Android Developers: Request runtime permissions](https://developer.android.com/training/permissions/requesting). The request workflow, permanent denial after two Deny taps, the `USER_SET` and `USER_FIXED` flags in `dumpsys`, `pm clear-permission-flags`, one-time permissions and process termination on revoke, the sample comment against linking to settings, and `install -g`.

- [Permission changes in Android 11](https://developer.android.com/about/versions/11/privacy/permissions). That the "don't ask again" behaviour after repeated denial and the *Only this time* option both start in Android 11.

- [Android Debug Bridge (adb) user guide](https://developer.android.com/tools/adb). The `pm grant` and `pm revoke` commands and the `-g` install option.

- [AOSP source: PackageManagerShellCommand.java (main)](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/services/core/java/com/android/server/pm/PackageManagerShellCommand.java). The `set-permission-flags` help text and its accepted flag list, and `reset-permissions` taking no package.

- [AOSP source: PermissionManagerServiceImpl.java (main)](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/services/core/java/com/android/server/pm/permission/PermissionManagerServiceImpl.java). The `shouldShowRequestPermissionRationale` logic over the fixed flags and `USER_SET`, and the grant path that logs and returns for a policy-fixed permission.

- [AOSP source: PackageManager.java (main)](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/content/pm/PackageManager.java). The permission flag names, including `ONE_TIME` and `POLICY_FIXED`.

- [DevicePolicyManager API reference](https://developer.android.com/reference/android/app/admin/DevicePolicyManager). `setPermissionGrantState`, the denied state where "the user cannot manage it through the UI", and which callers may use it.

- [AndroidX Test reference: GrantPermissionRule](https://developer.android.com/reference/androidx/test/rule/GrantPermissionRule). That grants apply to every test in the instrumentation and cannot be revoked without crashing it.

- [UI Automator reference: By](https://developer.android.com/reference/androidx/test/uiautomator/By). The `By.res(Pattern)` selector used to match the dialog's resource ids.

- [AOSP source: PermissionController grant_permissions.xml (main)](https://android.googlesource.com/platform/packages/modules/Permission/+/refs/heads/main/PermissionController/res/layout/grant_permissions.xml). The allow, one-time and deny button ids in the permission dialog.

- [Android 14: partial photo and video access](https://developer.android.com/about/versions/14/changes/partial-photo-video-access). `READ_MEDIA_VISUAL_USER_SELECTED` and reselection.

- [Appium UiAutomator2 driver README](https://github.com/appium/appium-uiautomator2-driver/blob/master/README.md). `appium:autoGrantPermissions`, `mobile: changePermissions`, `mobile: getPermissions`, and `mobile: shell` behind the `uiautomator2:adb_shell` feature.

- [Appium XCUITest driver: execute methods](https://github.com/appium/appium-xcuitest-driver/blob/master/docs/reference/execute-methods.md). `mobile: setPermission` services and `mobile: resetPermission` on simulators and real devices.

- [Appium XCUITest driver: capabilities](https://github.com/appium/appium-xcuitest-driver/blob/master/docs/reference/capabilities.md). `appium:autoAcceptAlerts` including privacy alerts, and the simulator-only `appium:permissions` capability.

- [Appium node-simctl: privacy subcommand](https://github.com/appium/node-simctl/blob/master/lib/subcommands/privacy.ts). The `simctl privacy` service names, with no camera entry.

- [Xcode 11.4 release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-11_4-release-notes). The introduction of `simctl privacy` for creating known permission states.

- [AVFoundation: AVAuthorizationStatus](https://developer.apple.com/documentation/avfoundation/avauthorizationstatus). The four status values and the meaning of `restricted`.

- [AVFoundation: requestAccess(for:completionHandler:)](https://developer.apple.com/documentation/avfoundation/avcapturedevice/requestaccess(for:completionhandler:)). That the system saves the selection and does not prompt again, and the exception without a usage description.

- [XCUIAutomation: resetAuthorizationStatus(for:)](https://developer.apple.com/documentation/xcuiautomation/xcuiapplication/resetauthorizationstatus(for:)) and [XCUIProtectedResource](https://developer.apple.com/documentation/xcuiautomation/xcuiprotectedresource). The reset API, its iOS 13.4 availability, and the `camera` case.

- [XCTest: Handling UI interruptions](https://developer.apple.com/documentation/xctest/handling-ui-interruptions). That interruption monitors are not for alerts the test expects.

- [XCUIAutomation: init(bundleIdentifier:)](https://developer.apple.com/documentation/xcuiautomation/xcuiapplication/init(bundleidentifier:)). Creating an application proxy by bundle identifier.

- [Xcode 16.3 release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-16_3-release-notes). The move of UI automation APIs into XCUIAutomation, re-exported by XCTest.

- [UIKit: openSettingsURLString](https://developer.apple.com/documentation/uikit/uiapplication/opensettingsurlstring). The deep link to the app's page in Settings.

- [Photos: PHAuthorizationStatus.limited](https://developer.apple.com/documentation/photos/phauthorizationstatus/limited). The limited-library state from iOS 14.

- [Apple Developer: Releases](https://developer.apple.com/news/releases/). Xcode 27 (27A266a) as the current release, Xcode 27.1 RC, and iOS 27.0.1.

## Continue reading on AutomationTester.in

- [Debug Vitest Module Mocking and Cache Leaks](https://automationtester.in/blog/automation-tutorials/debug-vitest-module-mocking-cache-leaks)
- [Run pytest in Parallel Without Shared-State Flakes](https://automationtester.in/blog/automation-tutorials/pytest-parallel-workers-shared-state-isolation)
- [Design pytest Fixtures as Explicit Resource Contracts](https://automationtester.in/blog/automation-tutorials/pytest-fixtures-explicit-resource-contracts)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Make TypeScript Type-Level Tests Earn Their Place](https://automationtester.in/blog/automation-tutorials/typescript-type-level-tests-contract-value)

Source: [Automate Mobile Permissions Without Brittle System UI](https://automationtester.in/blog/automation-tutorials/mobile-permission-testing-system-ui) by Shashank Rawlani.
