Makes every OS permission in an Expo app (camera, microphone, photos, location, contacts, calendar,
motion, notifications, Bluetooth) asked at the moment a person presses the control that needs it,
through one gate that knows the seven states, explains only when the control cannot, respects a
refusal, leads a blocked permission to the Settings app, and re-reads the state on every return to
the foreground; with a translated, specific purpose string in the build and a test for every outcome
on iOS and Android. App Tracking Transparency is admob-ads-and-consent's: it asks through the
ConsentPort, and this skill leaves it alone.
Rules that must hold
- Ask from the press that needs the permission. A request runs in the press handler of the
control (
gate.requestAccess(onAllowed)in the feature's model), never at launch, in the root layout or root navigator, in an effect, or at module scope. Why: a dialog without context is refused far more often, and on iOS that one refusal is final. (ask-from-action) - Every permission is in
contracts/permissions.json. One entry per kind: the feature it serves,askedFrom(the model that asks), platforms, the purpose-string key,shouldExplainFirstand the sheet copy keys; no code reads a kind the contract lacks, no entry outlives its code. Why: the inventory is what the reviewer, the store privacy forms and every other rule check against. (contract,undeclared-permission,contract-unused) - Only service adapters touch the OS, and they map every answer to the seven states.
granted,limited,provisional,undetermined,denied,blocked,unavailable:canAskAgaintells denied from blocked,accessPrivilegesand location accuracy give limited, a thrown call answers unavailable (never rejects). Why:status: 'denied'alone cannot say whether a dialog can still appear, and partial access is neither yes nor no. (request-boundary,can-ask-again,photos-limited) - Ask through
usePermissionGate, never a module's permission hook. The gate reads the state fresh at the press, runs one ask at a time and returns the sheet to show;useCameraPermissionsand friends ask with none of that. Why: Android throws on a second request while one is open, and a cached state misleads the decision. (system-hook) - Explain first only when the control does not explain the need, with exactly one "Continue".
The explain sheet opens the system dialog and has no close, cancel, "Not now" or "Allow" button,
no incentive and no picture of the alert; Android's rationale (after one refusal) has "Continue"
and "No thanks" (the contract's
explain.dismisskey is that "No thanks": only the rationale uses it, never the explain sheet). Why: Apple rejects pre-alert screens that offer a way around the alert or mimic it; Android requires a way out of the rationale. (asked-from,copy-keys) - Respect a refusal. After "Don't allow" nothing follows: no sheet, no Settings link, no retry; the control says the feature is off and the feature keeps a path without the permission. Why: Android's guidance forbids pushing people to Settings after a no, and App Review 5.1.1(iv) asks for alternatives.
- A blocked permission leads to the Settings app. On a later press the settings sheet says what
is off and opens
Linking.openSettings(); the app never re-requests a dialog that cannot appear. Why: without it, a blocked feature is a dead button. (open-settings) - The state on screen is re-read on every return to the foreground. Through the status mirror
(
useSyncExternalStoreover AppState), neveruseStateplus an effect or a stored flag. Why: people change permissions in Settings, Android resets unused apps and expires one-time grants, and neither OS reliably restarts the app. (recheck-on-active) - Every asked kind has its purpose string in the build, specific and translated. One active
sentence (what the feature does with it, where the data goes) from the catalogs through
sourceTextandnativeLocales; plugin options arefalseonly for permissions never asked. Why: iOS terminates an app that requests without the key, and a plugin's English default is vague in every language. (purpose-string,copy-keys) - Android never blocks a permission the app asks, and asks in steps. No asked permission in
blockedPermissionsor behind an expo-image-picker option set tofalse; background location only after foreground, in its own step; notification channels before the notification ask. Why: a blocked permission is refused without a dialog on every phone, and Android ignores combined requests. (android-blocked,location-foreground-first) - Ask for the least. The system photo picker (
launchImageLibraryAsync) needs no permission; add-only photo access to save, approximate before precise, when-in-use before always. Why: every permission is a dialog, a store declaration and a reason to say no. (picker-no-permission) - Every asking model is tested for every outcome on both platforms. Through
createFakePermissionAccess: granted, allowed, limited, declined, Android's rationale, blocked, unavailable, a change in Settings; E2E flows set permissions explicitly. Why: the outcomes people hit most (refused, blocked) are the ones a happy-path test never reaches. (asked-from)
Workflow
- Decide the inventory. Read references/asking-flow.md. For each
permission: which press asks it, whether the control explains the need (
shouldExplainFirst), what works without it, the copy. Prefer a permissionless API first (rule 11, references/platform-behaviour.md section 4). - Copy the templates (first line names each repo path):
templates/src/core/permissions/,templates/src/services/permissions/,templates/src/hooks/use-permission-gate.ts, their tests, andtemplates/contracts/permissions.json+permissions.schema.json. They importsrc/core/async/latest-request.ts(async-safety) and the error-log port (architecture-and-boundaries): set those up first. SetPermissionKindinpermissions-port.tsto the kinds you ask; with local-notifications or push-notifications, addnotificationsand bind it to their port (asking-flow section 12). - Write one adapter per kind like
examples/src/services/permissions/camera-permission-adapter.ts(createExpoPermissionAccessover the module's get/request pair). Classify the module incontracts/architecture.jsonvendorSdksand wirepermissionsintoServices(references/config-and-wiring.md sections 6-7). - Config. Give the plugin option
sourceText(projectRoot, '<purposeKey>'), setfalsefor everything never asked, keep asked permissions out ofblockedPermissions, list the key inNATIVE_TEXT_KEYS; regenerate the native snapshot (config-and-wiring sections 2-5). - Copy keys. Add
purposeKey,explain.*andsettings.*to every catalog (asking-flow section 13; the example catalogs show the Tasks camera texts), and fill the contract entry (examples/contracts/permissions.json). - Ask from the model. Call
usePermissionGatein the feature's model hook andgate.requestAccess(onAllowed)in the press handler (examples/src/features/task-photo/use-task-photo-model.ts); render the sheet from the app's dialog component (examples/.../components/permission-sheet-view.tsx). - Test every outcome with the fake (references/testing-permissions.md
sections 1-3;
use-task-photo-model.test.tsxis the shape). - Prove it on devices you name: a fresh install on a simulator and an emulator, Maestro flows with
explicit
permissions:(testing-permissions sections 4-7). - Run the checks:
node ${CLAUDE_SKILL_DIR}/scripts/check-permissions.mjs ., fix, rerun untilRESULT: PASS. What each rule reads: references/checker-rules.md; symptoms: references/pitfalls.md.
Definition of done
- Every permission the app asks is a
contracts/permissions.jsonentry whoseaskedFrommodel asks from a press throughusePermissionGate; no request in an effect, at module scope or at launch. - Only
src/services/**/*-adapter.tsfiles import permission functions; answers map throughtoPermissionState(canAskAgain, limited); no module permission hook anywhere. - The explain sheet (where used) has one "Continue"; Android's rationale has "Continue" and "No
thanks"; the settings sheet opens
Linking.openSettings(); nothing follows a refusal. - Each asked kind's purpose string is in the production Info.plist (snapshot regenerated), in every
language; unused plugin permissions are
false; no asked permission is blocked on Android; with a snapshot,expo-prebuild-and-config-plugins' native-snapshot check passes (it is current). - The asking model's test covers granted, declined, rationale (Android), blocked and unavailable;
npx jest --cipasses; the manual pass of testing-permissions section 7 is done on named devices. - A passing run may print only these SKIP lines while the app is being set up:
SKIP app.config.ts [purpose-string] not yet created (expo-project-bootstrap creates it),SKIP src/i18n/catalogs/en.json [copy-keys] not yet created (i18n-strings-and-catalogs creates it). -
node ${CLAUDE_SKILL_DIR}/scripts/check-permissions.mjs .printsRESULT: PASS.
Anti-patterns
- Asking at launch "to get it out of the way". The person has no reason to say yes yet; ask from the press that needs it.
- A pre-prompt with "Allow" and "Not now". Apple rejects it; the explain sheet has one "Continue", and most controls need no explain sheet at all.
if (!granted) requestPermissionsAsync()on every press. Once blocked, the request resolves at once and nothing happens; readcanAskAgainand show the settings sheet.- Linking to Settings right after a refusal. It reads as nagging; wait for the next press.
useCameraPermissions()in a screen. It asks without the explain, blocked and re-read rules; use the gate.- Requesting the photo library to pick one photo. The system picker needs no permission.
cameraPermission: false"to remove the English default". It deletes the key the app needs and, in expo-image-picker, blocks Android'sCAMERA; give it the translated text instead.- Treating
limitedas refused, or approximate location as off. Both are a yes. - A permission flag in a store or
useStateread once. It lies after a change in Settings; use the status mirror. - Testing only the granted path, or E2E flows that start with a bare
launchApp. Maestro grants everything by default; script refusals with the fake andpermissions:.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
references/asking-flow.md |
The flow: when to ask, the seven states, the decision table, Apple's and Android's sheet rules quoted, refusals, blocked, limited, copy | Workflow step 1, designing a sheet |
references/platform-behaviour.md |
iOS and Android per permission, Expo's answers read in source, dialog counts, changes behind the app's back, quirks | Step 1, a platform question |
references/config-and-wiring.md |
Plugin options and false, blocked permissions, purpose strings in every language, the native snapshot, wiring Services, SDK classification, multi-package |
Steps 3-4 |
references/testing-permissions.md |
The outcome matrix, fake and hook tests, simctl privacy, adb pm, Maestro permissions:, the manual pass |
Steps 7-8 |
references/checker-rules.md |
Every rule of check-permissions, the contract fields, SKIP and NOT APPLICABLE, limits | A FAIL line, changing the checker |
references/pitfalls.md |
Symptom, cause and fix for crashes, missing dialogs, lying states, review and test trouble | Something misbehaves |
templates/src/core/permissions/ |
permission-state.ts (the seven states, isPermissionAllowed, nextAskStep, pure) and its test |
Step 2 |
templates/src/services/permissions/ |
permissions-port.ts (PermissionAccess, PermissionKind, PermissionsPort); expo-permission-access.ts (toPermissionState, createExpoPermissionAccess, never rejects); system-settings-adapter.ts (Linking.openSettings()); permission-status-mirror.ts (the OS mirror that re-reads on the foreground); fake-permissions.ts (scripted iOS and Android dialog rules); each with its test |
Steps 2-3, 7 |
templates/src/hooks/ |
use-permission-gate.ts (fresh read, one ask at a time, the sheet, respect for a refusal) and its test |
Steps 2, 6 |
templates/contracts/ |
permissions.json (the empty contract) and permissions.schema.json |
Steps 2, 5 |
examples/src/services/permissions/camera-permission-adapter.ts |
A per-kind adapter over expo-image-picker (device-only) | Step 3 |
examples/src/features/task-photo/ |
use-task-photo-model.ts (asks the camera from "Take photo") with its outcome tests, components/permission-sheet-view.tsx (explain, rationale, settings and unavailable sheets as a pure view, one-button explain sheet) |
Steps 6-7 |
examples/contracts/permissions.json |
A filled contract entry (camera, with the optional unavailable copy) | Step 5 |
examples/src/i18n/catalogs/ |
The camera purpose string and sheet copy in en and ar |
Step 5 |
scripts/check-permissions.mjs |
The checker (16 rules) | Step 9, definition of done |
scripts/lib/ |
permission-scan.mjs (finds permission uses, effects, module scope, local imports); source-scan.mjs (shared lexer, synced, never edited here) |
Changing the checker |
scripts/selftest.mjs |
Runs the checker against every fixture | After changing the checker |
scripts/check-lib.mjs |
Shared checker library (synced, never edited here) | Never by hand |
assets/permission-apis.json |
Kinds (iOS keys, Android permissions, plugin options) and the SDK 57 permission functions | Adding a module or kind |
assets/shared.json |
The shared files this skill syncs | Syncing |
tests/bases/app/ |
The base app the fixtures build on | Changing fixtures |
tests/fixtures/ |
good, pass-, one bad- per rule, error-* cases | Changing the checker |
Related skills
admob-ads-and-consent- App Tracking Transparency through the ConsentPort.async-safety- the latest-wins helper the status mirror uses.push-notifications,local-notifications- their ports are thenotificationskind'sPermissionAccess.i18n-strings-and-catalogs- purpose strings and sheet copy in every language.expo-prebuild-and-config-plugins- plugin options, auto-applied plugins, the native snapshot.architecture-and-boundaries- the composition root and SDK classification.media-images-and-files- picking and capturing media once the permission allows it.privacy-and-store-declarations- store answers for every permission the contract lists.ui-component-library- the dialog or sheet the permission sheets render in.unit-and-component-tests,e2e-maestro- the test setup and flows with explicit permissions.