{"title":"Automate Mobile Permissions Without Brittle System UI","excerpt":"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.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/mobile-permission-testing-system-ui","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["mobile-testing","permissions","android","ios","xcuitest","appium","test-isolation"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-17T13:30:00.000Z","dateModified":"2026-10-14T10:26:51.547Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/mobile-permission-testing-system-ui.webp","alt":"Dark technical illustration read left to right. At top left, an orange arrowhead sends a dashed orange line into an orange dialog panel holding a text pill and two button pills, and the dashed line continues out to a crossed-out orange ring. Below it, inside a dashed green boundary, four stacked green panels each hold a grey track with a ringed green knob set at a different position. Green curves run from all four panels into a tall green phone outline in the centre with a large ringed dot. From the phone, four green lanes end in arrowheads at four screens on the right: one framed by corner brackets around a ringed dot, one with text pills and a bright button pill, one whose button pill sends an arrow out past its edge, and one holding only dim grey pills with no button. A single thin green line drops straight down from the phone to one small green dialog panel with two button pills, with a ringed dot beside it. Thin grey rules run along the top and bottom and faint pills sit at both edges."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> 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 <code class=\"language-text\">pm grant</code>, <code class=\"language-text\">pm revoke</code> and <code class=\"language-text\">pm set-permission-flags</code>, then read the state back, because <code class=\"language-text\">pm</code> can exit 0 without changing anything. On iOS, reset the camera with <code class=\"language-text\">resetAuthorizationStatus(for:)</code> 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.</div>\n\n<p>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.</p>\n\n<p>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 <a href=\"/blog/automation-tutorials/mobile-test-strategy-fidelity-cost\">first part</a> left the system prompt itself for this article; it is still here, confined to one test per platform.</p>\n\n<div class=\"callout callout-warning\"><strong>Versions checked on 2026-10-14.</strong> Appium 3.8.0 with the UiAutomator2 driver 8.7.0 and the XCUITest driver 12.18.0 (npm registry). AndroidX Test <code class=\"language-text\">rules</code> 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 <code class=\"language-text\">main</code> 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.</div>\n\n<h2 id=\"what-the-app-can-observe-per-platform\">What the app can observe, platform by platform</h2>\n\n<p>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.</p>\n\n<table>\n<thead>\n<tr><th>What happened</th><th>Android: what the app reads</th><th>iOS: what the app reads</th><th>Screen to assert</th></tr>\n</thead>\n<tbody>\n<tr><td>Never asked</td><td>Denied; rationale <code class=\"language-text\">false</code>; no user flags</td><td><code class=\"language-text\">.notDetermined</code></td><td>Explain, then request</td></tr>\n<tr><td>Allowed</td><td>Granted</td><td><code class=\"language-text\">.authorized</code></td><td>Viewfinder</td></tr>\n<tr><td>Denied once</td><td>Denied; rationale <code class=\"language-text\">true</code> (<code class=\"language-text\">USER_SET</code>)</td><td><code class=\"language-text\">.denied</code></td><td>Android: rationale, then ask again. iOS: settings link</td></tr>\n<tr><td>Denied twice</td><td>Denied; rationale <code class=\"language-text\">false</code> (<code class=\"language-text\">USER_FIXED</code>)</td><td>No separate state</td><td>Settings link</td></tr>\n<tr><td>Not the user's decision</td><td>Denied; rationale <code class=\"language-text\">false</code> (<code class=\"language-text\">POLICY_FIXED</code>)</td><td><code class=\"language-text\">.restricted</code></td><td>Explain the limitation</td></tr>\n</tbody>\n</table>\n\n<p>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, <code class=\"language-text\">shouldShowRequestPermissionRationale</code> returns <code class=\"language-text\">false</code> if any of <code class=\"language-text\">USER_FIXED</code>, <code class=\"language-text\">POLICY_FIXED</code> or <code class=\"language-text\">SYSTEM_FIXED</code> is set, and otherwise returns whether <code class=\"language-text\">USER_SET</code> 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.</p>\n\n<p>Android's permission guide names both flags: a permission denied once is flagged <code class=\"language-text\">USER_SET</code>, and one \"denied permanently by selecting Deny twice\" is flagged <code class=\"language-text\">USER_FIXED</code>. The policy row exists because a device owner or profile owner can call <code class=\"language-text\">DevicePolicyManager.setPermissionGrantState</code> 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.</p>\n\n<p>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. <code class=\"language-text\">.restricted</code> 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.</p>\n\n<p>On Android, \"granted\" is not always permanent. Since Android 11, camera, microphone and location prompts offer <em>Only this time</em>. 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.</p>\n\n<p>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.</p>\n\n<h2 id=\"put-the-state-to-screen-decision-in-plain-code\">Put the state-to-screen decision in plain code</h2>\n\n<p>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:</p>\n\n<pre class=\"language-swift\"><code class=\"language-swift\">import AVFoundation\n\npublic enum CameraScreen: Equatable, Sendable {\n    case capture            // authorized\n    case explainThenAsk     // notDetermined: in-app explainer, then requestAccess\n    case openSettings       // denied: the system will not prompt again\n    case explainLimitation  // restricted: the user cannot change it either\n}\n\npublic protocol CameraAuthorizing: Sendable {\n    func status() -&gt; AVAuthorizationStatus\n}\n\npublic struct SystemCamera: CameraAuthorizing {\n    public init() {}\n    public func status() -&gt; AVAuthorizationStatus {\n        AVCaptureDevice.authorizationStatus(for: .video)\n    }\n}\n\npublic func cameraScreen(_ camera: some CameraAuthorizing) -&gt; CameraScreen {\n    switch camera.status() {\n    case .authorized: return .capture\n    case .notDetermined: return .explainThenAsk\n    case .denied: return .openSettings\n    case .restricted: return .explainLimitation\n    @unknown default: return .explainLimitation\n    }\n}</code></pre>\n\n<pre class=\"language-swift\"><code class=\"language-swift\">import AVFoundation\nimport Testing\n\nstruct FixedCamera: CameraAuthorizing {\n    let value: AVAuthorizationStatus\n    func status() -&gt; AVAuthorizationStatus { value }\n}\n\n@Test(arguments: [\n    (AVAuthorizationStatus.authorized, CameraScreen.capture),\n    (.notDetermined, .explainThenAsk),\n    (.denied, .openSettings),\n    (.restricted, .explainLimitation),\n])\nfunc screenFollowsAuthorization(status: AVAuthorizationStatus, expected: CameraScreen) {\n    #expect(cameraScreen(FixedCamera(value: status)) == expected)\n}</code></pre>\n\n<p><code class=\"language-text\">AVAuthorizationStatus</code> 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:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">3 capture ok\n0 explainThenAsk ok\n2 openSettings ok\n1 explainLimitation ok</code></pre>\n\n<p>On Android the mistake is in the branch order. This version reads naturally and ships a dead button:</p>\n\n<pre class=\"language-kotlin\"><code class=\"language-kotlin\">// Wrong: treats \"no rationale\" as \"safe to ask\".\nfun onScanTapped() {\n    when {\n        hasCameraPermission() -&gt; openViewfinder()\n        shouldShowRequestPermissionRationale(CAMERA) -&gt; showRationale()\n        else -&gt; requestCamera.launch(CAMERA) // blocked: no dialog, callback gets false\n    }\n}</code></pre>\n\n<p>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:</p>\n\n<pre class=\"language-kotlin\"><code class=\"language-kotlin\">enum class CameraScreen { VIEWFINDER, EXPLAIN_THEN_ASK, RATIONALE, OPEN_SETTINGS }\n\nfun cameraScreen(granted: Boolean, rationale: Boolean, askedBefore: Boolean): CameraScreen =\n    when {\n        granted -&gt; CameraScreen.VIEWFINDER\n        rationale -&gt; CameraScreen.RATIONALE            // USER_SET, nothing fixed\n        !askedBefore -&gt; CameraScreen.EXPLAIN_THEN_ASK  // no flags yet\n        else -&gt; CameraScreen.OPEN_SETTINGS             // USER_FIXED or POLICY_FIXED\n    }\n\nclass CameraScreenTest {\n    @Test\n    fun blockedIsNotNeverAsked() {\n        assertEquals(CameraScreen.EXPLAIN_THEN_ASK, cameraScreen(false, false, askedBefore = false))\n        assertEquals(CameraScreen.OPEN_SETTINGS, cameraScreen(false, false, askedBefore = true))\n    }\n}</code></pre>\n\n<p>A user block and a policy lock both land on <code class=\"language-text\">OPEN_SETTINGS</code>, 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.</p>\n\n<h2 id=\"seed-android-grants-and-flags-together\">Seed Android grants and flags together</h2>\n\n<p>Device tests still need real platform state, because the instrumented build calls the real <code class=\"language-text\">checkSelfPermission</code>. The shell's package manager reaches every user-controlled row in the table. <code class=\"language-text\">grant</code> and <code class=\"language-text\">revoke</code> are documented on the adb page, and the guide documents <code class=\"language-text\">clear-permission-flags</code>. <code class=\"language-text\">set-permission-flags</code> appears in the <code class=\"language-text\">pm</code> help text in AOSP, which limits the flags it accepts to <code class=\"language-text\">review-required</code>, <code class=\"language-text\">revoked-compat</code>, <code class=\"language-text\">revoke-when-requested</code>, <code class=\"language-text\">user-fixed</code> and <code class=\"language-text\">user-set</code>.</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">#!/usr/bin/env bash\n# Seed one camera permission state before the app starts.\n# Usage: seed-camera.sh granted|not-asked|denied-once|blocked\nset -euo pipefail\nPKG=com.example.scanner\nPERM=android.permission.CAMERA\nSTATE=${1:?state required}\n\nadb shell am force-stop \"$PKG\"           # revoking kills the process anyway\n\ncase \"$STATE\" in\n  granted)\n    adb shell pm grant \"$PKG\" \"$PERM\" ;;\n  not-asked)\n    adb shell pm revoke \"$PKG\" \"$PERM\"\n    adb shell pm clear-permission-flags \"$PKG\" \"$PERM\" user-set user-fixed ;;\n  denied-once)\n    adb shell pm revoke \"$PKG\" \"$PERM\"\n    adb shell pm clear-permission-flags \"$PKG\" \"$PERM\" user-fixed\n    adb shell pm set-permission-flags \"$PKG\" \"$PERM\" user-set ;;\n  blocked)\n    adb shell pm revoke \"$PKG\" \"$PERM\"\n    adb shell pm set-permission-flags \"$PKG\" \"$PERM\" user-set user-fixed ;;\n  *)\n    echo \"unknown state: $STATE\" &gt;&amp;2; exit 2 ;;\nesac\n\n# pm can exit 0 without changing anything, so read the state back.\ndump=$(adb shell dumpsys package \"$PKG\")\nline=$(grep -m1 \"$PERM: granted=\" &lt;&lt;&lt;\"$dump\")\necho \"$line\"\nhas() { grep -q \"$1\" &lt;&lt;&lt;\"$line\"; }\ncase \"$STATE\" in\n  granted)     has 'granted=true' ;;\n  not-asked)   has 'granted=false' &amp;&amp; ! has 'USER_SET' &amp;&amp; ! has 'USER_FIXED' ;;\n  denied-once) has 'granted=false' &amp;&amp; has 'USER_SET' &amp;&amp; ! has 'USER_FIXED' ;;\n  blocked)     has 'granted=false' &amp;&amp; has 'USER_FIXED' ;;\nesac || { echo \"seed did not take: wanted $STATE\" &gt;&amp;2; exit 1; }</code></pre>\n\n<p>The read-back is there for a reason found in the source. When a permission is <code class=\"language-text\">POLICY_FIXED</code>, 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 <code class=\"language-text\">adb</code> printing canned <code class=\"language-text\">dumpsys</code> blocks: it accepted each correct state and exited 1 for a blocked seed missing <code class=\"language-text\">USER_FIXED</code> and for a grant that a policy lock had ignored.</p>\n\n<p>Four other details decide whether this setup stays honest:</p>\n\n<ul>\n<li>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.</li>\n<li>Seed the app's own memory to match. For <code class=\"language-text\">denied-once</code> and <code class=\"language-text\">blocked</code>, launch with a debug-only extra such as <code class=\"language-text\">adb shell am start -n com.example.scanner/.ScanActivity --ez seed_camera_asked true</code>, so the remembered flag agrees with the platform flags.</li>\n<li><code class=\"language-text\">pm reset-permissions</code> 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.</li>\n<li><code class=\"language-text\">adb install -g</code> grants every manifest permission at install. That suits suites that are not about permissions, but under it the never-asked path never runs.</li>\n</ul>\n\n<p>The AndroidX rule many suites start with has a sharper edge. <code class=\"language-text\">GrantPermissionRule</code>'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:</p>\n\n<pre class=\"language-kotlin\"><code class=\"language-kotlin\">// Wrong: grants CAMERA for the whole instrumentation run, not this class.\n@get:Rule\nval camera: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA)\n\n// Right for permission-state tests: no rule. The runner seeds the state\n// with seed-camera.sh before launching this class, one state per invocation.\n@Test\nfun blockedCameraShowsSettingsLinkOnlyAfterTap() {\n    launchActivity&lt;ScanActivity&gt;().use {\n        onView(withId(R.id.open_settings)).check(doesNotExist())\n        onView(withId(R.id.scan_start)).perform(click())\n        onView(withId(R.id.open_settings)).check(matches(isDisplayed()))\n    }\n}</code></pre>\n\n<p>If the suite runs through Appium, the UiAutomator2 driver's <code class=\"language-text\">mobile: changePermissions</code> covers grant and revoke for the <code class=\"language-text\">pm</code> target, and <code class=\"language-text\">mobile: getPermissions</code> reads back granted or denied lists. Neither touches flags, so the denied-once and blocked rows need <code class=\"language-text\">mobile: shell</code>, which the driver enables only with the <code class=\"language-text\">uiautomator2:adb_shell</code> server feature. That belongs in the reviewed server config described in the <a href=\"/blog/automation-tutorials/appium-drivers-plugins-test-boundaries\">Appium extensions part</a> of this series. This helper type-checks against WebdriverIO 10.0.2 with TypeScript 7.0.2 under <code class=\"language-text\">--strict</code>:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">import type { Browser } from 'webdriverio';\n\ntype CameraState = 'granted' | 'not-asked' | 'denied-once' | 'blocked';\n\nconst PKG = 'com.example.scanner';\nconst CAMERA = 'android.permission.CAMERA';\nconst FLAGS: Record&lt;Exclude&lt;CameraState, 'granted'&gt;, string[]&gt; = {\n  'not-asked': [],\n  'denied-once': ['user-set'],\n  blocked: ['user-set', 'user-fixed'],\n};\n\nconst pm = (driver: Browser, ...args: string[]) =&gt;\n  driver.execute('mobile: shell', { command: 'pm', args });\n\nexport async function seedCamera(driver: Browser, state: CameraState): Promise&lt;void&gt; {\n  // Revoking a runtime permission kills the process, so stop it first and seed cold.\n  await driver.execute('mobile: terminateApp', { appId: PKG });\n\n  if (state === 'granted') {\n    await driver.execute('mobile: changePermissions', {\n      permissions: CAMERA, appPackage: PKG, action: 'grant',\n    });\n  } else {\n    await driver.execute('mobile: changePermissions', {\n      permissions: CAMERA, appPackage: PKG, action: 'revoke',\n    });\n    // changePermissions cannot touch flags; this needs uiautomator2:adb_shell.\n    await pm(driver, 'clear-permission-flags', PKG, CAMERA, 'user-set', 'user-fixed');\n    const flags = FLAGS[state];\n    if (flags.length &gt; 0) {\n      await pm(driver, 'set-permission-flags', PKG, CAMERA, ...flags);\n    }\n  }\n\n  const granted = (await driver.execute('mobile: getPermissions', {\n    type: 'granted', appPackage: PKG,\n  })) as string[];\n  if (granted.includes(CAMERA) !== (state === 'granted')) {\n    throw new Error(`camera seed failed: wanted ${state}, granted=${granted.includes(CAMERA)}`);\n  }\n\n  await driver.execute('mobile: activateApp', { appId: PKG });\n}</code></pre>\n\n<p>The policy row cannot be seeded this way at all: <code class=\"language-text\">policy-fixed</code> is not in the flag list <code class=\"language-text\">pm</code> 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.</p>\n\n<h2 id=\"ios-reset-the-camera-and-inject-the-rest\">iOS: reset the camera, inject the rest</h2>\n\n<p>Simulators have had <code class=\"language-text\">xcrun simctl privacy &lt;device&gt; grant|revoke|reset &lt;service&gt; &lt;bundle id&gt;</code> 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 <code class=\"language-text\">xcrun simctl help privacy</code> on your own Xcode before relying on that, since the list belongs to the tool version.</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Works for the services simctl knows; camera is not one of them.\nxcrun simctl privacy booted grant microphone com.example.scanner\nxcrun simctl privacy booted reset all com.example.scanner</code></pre>\n\n<p>For the camera, the documented switch is <code class=\"language-text\">XCUIApplication.resetAuthorizationStatus(for:)</code> with <code class=\"language-text\">XCUIProtectedResource.camera</code>, available from iOS 13.4. It returns the app to <code class=\"language-text\">.notDetermined</code>, and the XCUITest driver's <code class=\"language-text\">mobile: resetPermission</code>, 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:</p>\n\n<pre class=\"language-swift\"><code class=\"language-swift\">// App target\n#if DEBUG\nstruct SeededCamera: CameraAuthorizing {\n    let value: AVAuthorizationStatus\n    func status() -&gt; AVAuthorizationStatus { value }\n}\n\nfunc makeCameraAuthorizer(\n    arguments: [String] = ProcessInfo.processInfo.arguments\n) -&gt; any CameraAuthorizing {\n    guard let flag = arguments.firstIndex(of: \"-seedCamera\"),\n          arguments.indices.contains(flag + 1) else { return SystemCamera() }\n    switch arguments[flag + 1] {\n    case \"authorized\": return SeededCamera(value: .authorized)\n    case \"denied\": return SeededCamera(value: .denied)\n    case \"restricted\": return SeededCamera(value: .restricted)\n    default: return SystemCamera()   // \"notDetermined\" uses the real API after a reset\n    }\n}\n#else\nfunc makeCameraAuthorizer() -&gt; any CameraAuthorizing { SystemCamera() }\n#endif</code></pre>\n\n<pre class=\"language-swift\"><code class=\"language-swift\">// UI test target\nimport XCTest\n\nfinal class CameraStateScreenTests: XCTestCase {\n    private func launch(seeding state: String) -&gt; XCUIApplication {\n        let app = XCUIApplication()\n        app.launchArguments += [\"-seedCamera\", state]\n        app.launch()\n        return app\n    }\n\n    func testDeniedOffersSettingsAfterTap() {\n        let app = launch(seeding: \"denied\")\n        XCTAssertFalse(app.buttons[\"camera.openSettings\"].exists)\n        app.buttons[\"scan.start\"].tap()\n        XCTAssertTrue(app.buttons[\"camera.openSettings\"].waitForExistence(timeout: 5))\n    }\n\n    func testRestrictedExplainsWithoutSettingsLink() {\n        let app = launch(seeding: \"restricted\")\n        app.buttons[\"scan.start\"].tap()\n        XCTAssertTrue(app.staticTexts[\"camera.restrictedExplanation\"].waitForExistence(timeout: 5))\n        XCTAssertFalse(app.buttons[\"camera.openSettings\"].exists)\n    }\n}</code></pre>\n\n<p>The seeding factory compiled with <code class=\"language-text\">-D DEBUG</code> 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 <code class=\"language-text\">import XCTest</code> re-exports it, so the test file needs no extra import.</p>\n\n<p>The trade is a test seam in shipping code, kept out of release builds by <code class=\"language-text\">#if DEBUG</code>. 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, <code class=\"language-text\">UIApplication.openSettingsURLString</code> 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.</p>\n\n<h2 id=\"one-real-prompt-per-platform\">One real prompt per platform</h2>\n\n<p>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 <code class=\"language-text\">NSCameraUsageDescription</code>, and calling <code class=\"language-text\">requestAccess</code> or starting a capture session without it \"raises an exception.\" That makes it worth one test, started from a reset state:</p>\n\n<pre class=\"language-swift\"><code class=\"language-swift\">final class CameraPromptReceiptTests: XCTestCase {\n    // OS-owned copy. The only place the suite depends on it.\n    private let allowLabel = \"Allow\"\n\n    func testFirstScanShowsSystemPromptWithOurPurpose() {\n        let app = XCUIApplication()\n        app.resetAuthorizationStatus(for: .camera)\n        app.launch()\n        app.buttons[\"scan.start\"].tap()\n        app.buttons[\"camera.explainer.continue\"].tap()\n\n        let springboard = XCUIApplication(bundleIdentifier: \"com.apple.springboard\")\n        let alert = springboard.alerts.firstMatch\n        XCTAssertTrue(alert.waitForExistence(timeout: 10))\n        let purpose = alert.staticTexts.matching(\n            NSPredicate(format: \"label CONTAINS %@\", \"scan shelf labels\")).firstMatch\n        XCTAssertTrue(purpose.exists, \"purpose string missing from the prompt\")\n\n        alert.buttons[allowLabel].tap()\n        XCTAssertTrue(app.otherElements[\"camera.viewfinder\"].waitForExistence(timeout: 5))\n    }\n}</code></pre>\n\n<p>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 <code class=\"language-text\">init(bundleIdentifier:)</code> does not promise which process hosts system alerts. That uncertainty is another reason to keep it to one test.</p>\n\n<p>On Android, the dialog's buttons are defined in the PermissionController module's layout in AOSP, with ids such as <code class=\"language-text\">permission_allow_button</code>, <code class=\"language-text\">permission_allow_foreground_only_button</code>, <code class=\"language-text\">permission_allow_one_time_button</code> and <code class=\"language-text\">permission_deny_button</code>. Matching on the id suffix with a pattern avoids depending on button copy and on which package hosts the dialog on a given build:</p>\n\n<pre class=\"language-kotlin\"><code class=\"language-kotlin\">@Test\nfun firstScanShowsSystemDialog() {\n    // Runner seeded \"not-asked\" before this test.\n    val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())\n    launchActivity&lt;ScanActivity&gt;().use {\n        onView(withId(R.id.scan_start)).perform(click())\n        onView(withId(R.id.explainer_continue)).perform(click())\n\n        val allow = device.wait(\n            Until.findObject(By.res(Pattern.compile(\".*:id/permission_allow(_foreground_only)?_button\"))),\n            10_000,\n        )\n        assertNotNull(\"system camera dialog did not appear\", allow)\n        allow.click()\n        onView(withId(R.id.viewfinder)).check(matches(isDisplayed()))\n    }\n}</code></pre>\n\n<p>Those ids come from AOSP; a vendor build can change them, which is exactly why this check runs on the release devices from the <a href=\"/blog/automation-tutorials/mobile-device-matrix-real-risk\">device matrix</a> before a release, not on every pull request. The receipts cover the joint between your app and the OS; everything else covers behaviour.</p>\n\n<h2 id=\"failures-that-come-from-leftover-state\">Failures that come from leftover state</h2>\n\n<p><strong>The Android dialog never appears.</strong> The cause is usually a <code class=\"language-text\">USER_FIXED</code> flag left by an earlier test or run, which turns the request into an immediate denial. The check: <code class=\"language-text\">adb shell dumpsys package com.example.scanner</code> 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.</p>\n\n<p><strong>The app vanishes right after setup.</strong> 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.</p>\n\n<p><strong>A granted permission disappears after the app is backgrounded.</strong> A receipt test, or a person at the device, picked <em>Only this time</em>, and the grant expired once the app left the foreground. Android's flag names include <code class=\"language-text\">ONE_TIME</code> alongside <code class=\"language-text\">USER_SET</code> and <code class=\"language-text\">USER_FIXED</code>, so the same <code class=\"language-text\">dumpsys</code> line tells this case apart from the first. Seed <code class=\"language-text\">granted</code> with <code class=\"language-text\">pm grant</code> rather than inheriting whatever the receipt left.</p>\n\n<p><strong>The iOS receipt waits for an alert that never comes.</strong> A previous run on the same simulator already answered, and the status is no longer <code class=\"language-text\">.notDetermined</code>. The check is whether <code class=\"language-text\">resetAuthorizationStatus(for: .camera)</code> runs before <code class=\"language-text\">launch()</code>. Without the reset, the test passes only on fresh simulators.</p>\n\n<h2 id=\"seed-the-blocked-state-today\">Seed the blocked state today</h2>\n\n<p>Pick the one permission your most-used feature depends on. On Android, run the seed script with <code class=\"language-text\">blocked</code>, launch, tap the feature, and check that the screen offers a way forward rather than a button that does nothing. On iOS, launch with <code class=\"language-text\">-seedCamera denied</code> and then <code class=\"language-text\">restricted</code>, 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.</p>\n\n<p>Keep three pieces of evidence: the <code class=\"language-text\">dumpsys</code> 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.</p>\n\n<h2 id=\"questions-about-permission-automation\">Questions about permission automation</h2>\n\n<h3 id=\"faq-auto-grant-and-auto-accept\">Why not set autoGrantPermissions or autoAcceptAlerts and move on?</h3>\n\n<p>For suites whose subject is not permissions, do. UiAutomator2's <code class=\"language-text\">appium:autoGrantPermissions</code> grants every requested permission when the session starts, and the XCUITest driver's <code class=\"language-text\">appium:autoAcceptAlerts</code> 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.</p>\n\n<h3 id=\"faq-photos-and-partial-access\">Does the camera matrix carry over to photos?</h3>\n\n<p>Not unchanged. Photo access has a fifth state on both platforms: <code class=\"language-text\">PHAuthorizationStatus.limited</code> on iOS 14 and later, and partial access through <code class=\"language-text\">READ_MEDIA_VISUAL_USER_SELECTED</code> 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.</p>\n\n<h3 id=\"faq-ai-written-state-matrix\">Can an AI assistant write the state matrix?</h3>\n\n<p>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.</p>\n\n<h2 id=\"permission-automation-primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://developer.android.com/training/permissions/requesting\">Android Developers: Request runtime permissions</a>. The request workflow, permanent denial after two Deny taps, the <code class=\"language-text\">USER_SET</code> and <code class=\"language-text\">USER_FIXED</code> flags in <code class=\"language-text\">dumpsys</code>, <code class=\"language-text\">pm clear-permission-flags</code>, one-time permissions and process termination on revoke, the sample comment against linking to settings, and <code class=\"language-text\">install -g</code>.</li>\n<li><a href=\"https://developer.android.com/about/versions/11/privacy/permissions\">Permission changes in Android 11</a>. That the \"don't ask again\" behaviour after repeated denial and the <em>Only this time</em> option both start in Android 11.</li>\n<li><a href=\"https://developer.android.com/tools/adb\">Android Debug Bridge (adb) user guide</a>. The <code class=\"language-text\">pm grant</code> and <code class=\"language-text\">pm revoke</code> commands and the <code class=\"language-text\">-g</code> install option.</li>\n<li><a href=\"https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/services/core/java/com/android/server/pm/PackageManagerShellCommand.java\">AOSP source: PackageManagerShellCommand.java (main)</a>. The <code class=\"language-text\">set-permission-flags</code> help text and its accepted flag list, and <code class=\"language-text\">reset-permissions</code> taking no package.</li>\n<li><a href=\"https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/services/core/java/com/android/server/pm/permission/PermissionManagerServiceImpl.java\">AOSP source: PermissionManagerServiceImpl.java (main)</a>. The <code class=\"language-text\">shouldShowRequestPermissionRationale</code> logic over the fixed flags and <code class=\"language-text\">USER_SET</code>, and the grant path that logs and returns for a policy-fixed permission.</li>\n<li><a href=\"https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/content/pm/PackageManager.java\">AOSP source: PackageManager.java (main)</a>. The permission flag names, including <code class=\"language-text\">ONE_TIME</code> and <code class=\"language-text\">POLICY_FIXED</code>.</li>\n<li><a href=\"https://developer.android.com/reference/android/app/admin/DevicePolicyManager\">DevicePolicyManager API reference</a>. <code class=\"language-text\">setPermissionGrantState</code>, the denied state where \"the user cannot manage it through the UI\", and which callers may use it.</li>\n<li><a href=\"https://developer.android.com/reference/androidx/test/rule/GrantPermissionRule\">AndroidX Test reference: GrantPermissionRule</a>. That grants apply to every test in the instrumentation and cannot be revoked without crashing it.</li>\n<li><a href=\"https://developer.android.com/reference/androidx/test/uiautomator/By\">UI Automator reference: By</a>. The <code class=\"language-text\">By.res(Pattern)</code> selector used to match the dialog's resource ids.</li>\n<li><a href=\"https://android.googlesource.com/platform/packages/modules/Permission/+/refs/heads/main/PermissionController/res/layout/grant_permissions.xml\">AOSP source: PermissionController grant_permissions.xml (main)</a>. The allow, one-time and deny button ids in the permission dialog.</li>\n<li><a href=\"https://developer.android.com/about/versions/14/changes/partial-photo-video-access\">Android 14: partial photo and video access</a>. <code class=\"language-text\">READ_MEDIA_VISUAL_USER_SELECTED</code> and reselection.</li>\n<li><a href=\"https://github.com/appium/appium-uiautomator2-driver/blob/master/README.md\">Appium UiAutomator2 driver README</a>. <code class=\"language-text\">appium:autoGrantPermissions</code>, <code class=\"language-text\">mobile: changePermissions</code>, <code class=\"language-text\">mobile: getPermissions</code>, and <code class=\"language-text\">mobile: shell</code> behind the <code class=\"language-text\">uiautomator2:adb_shell</code> feature.</li>\n<li><a href=\"https://github.com/appium/appium-xcuitest-driver/blob/master/docs/reference/execute-methods.md\">Appium XCUITest driver: execute methods</a>. <code class=\"language-text\">mobile: setPermission</code> services and <code class=\"language-text\">mobile: resetPermission</code> on simulators and real devices.</li>\n<li><a href=\"https://github.com/appium/appium-xcuitest-driver/blob/master/docs/reference/capabilities.md\">Appium XCUITest driver: capabilities</a>. <code class=\"language-text\">appium:autoAcceptAlerts</code> including privacy alerts, and the simulator-only <code class=\"language-text\">appium:permissions</code> capability.</li>\n<li><a href=\"https://github.com/appium/node-simctl/blob/master/lib/subcommands/privacy.ts\">Appium node-simctl: privacy subcommand</a>. The <code class=\"language-text\">simctl privacy</code> service names, with no camera entry.</li>\n<li><a href=\"https://developer.apple.com/documentation/xcode-release-notes/xcode-11_4-release-notes\">Xcode 11.4 release notes</a>. The introduction of <code class=\"language-text\">simctl privacy</code> for creating known permission states.</li>\n<li><a href=\"https://developer.apple.com/documentation/avfoundation/avauthorizationstatus\">AVFoundation: AVAuthorizationStatus</a>. The four status values and the meaning of <code class=\"language-text\">restricted</code>.</li>\n<li><a href=\"https://developer.apple.com/documentation/avfoundation/avcapturedevice/requestaccess(for:completionhandler:)\">AVFoundation: requestAccess(for:completionHandler:)</a>. That the system saves the selection and does not prompt again, and the exception without a usage description.</li>\n<li><a href=\"https://developer.apple.com/documentation/xcuiautomation/xcuiapplication/resetauthorizationstatus(for:)\">XCUIAutomation: resetAuthorizationStatus(for:)</a> and <a href=\"https://developer.apple.com/documentation/xcuiautomation/xcuiprotectedresource\">XCUIProtectedResource</a>. The reset API, its iOS 13.4 availability, and the <code class=\"language-text\">camera</code> case.</li>\n<li><a href=\"https://developer.apple.com/documentation/xctest/handling-ui-interruptions\">XCTest: Handling UI interruptions</a>. That interruption monitors are not for alerts the test expects.</li>\n<li><a href=\"https://developer.apple.com/documentation/xcuiautomation/xcuiapplication/init(bundleidentifier:)\">XCUIAutomation: init(bundleIdentifier:)</a>. Creating an application proxy by bundle identifier.</li>\n<li><a href=\"https://developer.apple.com/documentation/xcode-release-notes/xcode-16_3-release-notes\">Xcode 16.3 release notes</a>. The move of UI automation APIs into XCUIAutomation, re-exported by XCTest.</li>\n<li><a href=\"https://developer.apple.com/documentation/uikit/uiapplication/opensettingsurlstring\">UIKit: openSettingsURLString</a>. The deep link to the app's page in Settings.</li>\n<li><a href=\"https://developer.apple.com/documentation/photos/phauthorizationstatus/limited\">Photos: PHAuthorizationStatus.limited</a>. The limited-library state from iOS 14.</li>\n<li><a href=\"https://developer.apple.com/news/releases/\">Apple Developer: Releases</a>. Xcode 27 (27A266a) as the current release, Xcode 27.1 RC, and iOS 27.0.1.</li>\n</ul>\n"}