Native Capabilities

Lesson 1 of 5

Permissions Are a State Machine

"It's easier to ask forgiveness than it is to get permission." — Grace Hopper, quoted in Chips (1986)

Not on a phone it isn't.

A permission is the first thing standing between your app and every capability that makes a phone different from a browser — the camera, the location, the contacts, the ability to send a notification. And the mental model almost everyone starts with is a boolean: ask, get yes or no, branch.

It is not a boolean. It is a small state machine with at least four states, transitions that only go one way, and one pair of states that React Native's own API cannot tell apart. I can show you that last one exactly, because the native interface is four methods long and two of them return booleans.

Getting this wrong does not produce a crash. It produces an app that asks a user for their camera at launch, gets a no, and then shows them a broken screen forever — which is a product failure that looks like a bug and is really a missing state.

The big question

Both platforms deliberately limit how many times you may ask. iOS shows its dialog once per permission, ever; Android stops showing it after the user has said no firmly enough. That is a protection for users against exactly the behaviour a growth metric would encourage. It also means a single badly-timed request permanently costs you a capability, with no way back inside your app. How should an application be designed when its most important question can only be asked once?

The API is four methods, and two of them are booleans

Here is the entire native interface, from NativePermissionsAndroid.js:

export interface Spec extends TurboModule {
  +checkPermission: (permission: PermissionType) => Promise<boolean>;
  +requestPermission: (permission: PermissionType) => Promise<PermissionStatus>;
  +shouldShowRequestPermissionRationale: (permission: string) => Promise<boolean>;
  +requestMultiplePermissions: (
    permissions: Array<PermissionType>,
  ) => Promise<{[permission: PermissionType]: PermissionStatus, ...}>;
}

PermissionStatus is 'granted' | 'denied' | 'never_ask_again' — three values. And the JavaScript wrapper exposes those as PermissionsAndroid.RESULTS, alongside 48 permission constants.

Note the asymmetry. request returns three states. check returns a boolean. So the API that asks tells you more than the API that looks, and the one you want to call on every screen load is the lossy one.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

That is the finding, and it is worth stating plainly: Android's permission API cannot distinguish "never asked" from "permanently blocked". Both report not-granted and no-rationale-needed. The only way to tell them apart is to remember, in your own storage, whether you have ever asked.

An analogy

A door with no letterbox. You knock; either it opens or it does not. If it does not open, you cannot tell from outside whether nobody is home, whether they heard you and chose not to answer, or whether they have decided never to answer you again — the view from the doorstep is identical in all three cases. What changes your behaviour is not anything about the door; it is your own memory of having knocked. Knocking once on a door you have never tried is reasonable. Knocking on a door you have already knocked on twice today is something else, and the only thing that makes those different is a record you keep. Permissions work exactly this way, and the mistake developers make is looking harder at the door.

The four states, and which way the arrows point

Write the machine out and the design decisions become visible.

Undetermined is the starting state. No dialog has been shown, and asking will show one. This is the only state in which a request actually reaches the user.

Denied means the user said no once and the platform will still let you ask again. On Android this is where shouldShowRequestPermissionRationale returns true, which is the system telling you: they have refused, so explain yourself before asking again.

Blocked means asking is now a no-op. On iOS this happens after a single refusal; on Android after the user has refused firmly enough. The request call still resolves — it just resolves to a denial without showing anything. From here the only route to granted runs through the operating system's Settings app. Core React Native's Linking.openSettings() will take the user straight to your app's settings page, which is the difference between a recoverable dead end and an unrecoverable one — but only the user can flip the switch once they are there.

Granted is the goal, and it is not permanent either: the user can revoke in Settings at any time, and recent Android versions auto-revoke permissions for apps that have not been opened in a while. A permission you were granted last week is not a permission you have today.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

The second point is the one that changes product decisions. You get a small, fixed number of chances, and you spend one every time you call request. An app that asks for location on first launch, before the user knows what the app does, has spent its only chance on its least persuasive moment.

A little history

Android permissions were not always a runtime question. Until Android 6 in 2015, every permission an app declared was granted at install time as a single take-it-or-leave-it list — you saw a wall of requests before you had used anything, and the only options were accept all or do not install. That model failed in both directions: users granted things they did not understand because the alternative was not having the app, and developers had no signal about what anybody actually wanted. Runtime permissions replaced it with a request at the moment of use, which is better on every axis and introduced exactly the state machine this chapter is about — because a question you can ask later is a question with a history. iOS had runtime prompts from much earlier and made a different trade: one prompt per permission, ever, with no second chance at all. Both platforms have since tightened rather than loosened — Android added one-time grants and auto-revocation for unused apps, iOS added a "while using the app" tier and approximate location — and every one of those additions puts another state into a machine that most codebases still model as a boolean.

check is a boolean and your UI needs three answers

The practical consequence in a React Native codebase is that PermissionsAndroid.check is almost never enough on its own.

A screen that needs the camera has three legitimate renderings: ask for it, explain and ask again, or send the user to Settings. A boolean can only choose between two.

React — Try it Yourself
Loading editor…
Preview

The blocked screen is the one that gets skipped, and it is the one that matters most: a user who has blocked a permission and wants it back has no way to discover that Settings is the answer unless you tell them.

From my notebook

I went into the source expecting the usual shape — a rich native API with a thin wrapper — and found the opposite. The native spec is four methods, two of them return plain booleans, and between them they genuinely cannot separate "we have never asked" from "we asked and were permanently refused". I checked that by writing out what each call returns in each state, and the two rows are identical. My first reaction was that this is a gap somebody should fix; my second, after thinking about what the platform could even do, is that it is not fixable from inside the API. Android could expose it, but the reason it does not is the same reason the dialog stops appearing: the platform is deliberately unhelpful about how thoroughly a user has refused you, because an app that knew would optimise against it. So the missing state genuinely has to live in your storage, and the uncomfortable implication is that permission handling is not something you can get right by reading the API docs — it requires you to keep a record the API has decided not to keep for you.

Asking well

Everything above is mechanics. The part that decides whether users say yes is timing, and it follows directly from the state machine.

Ask at the moment of use, never at launch. A request at launch is asked before the user knows what the app is for, and it is the least persuasive moment you will ever have. The same request made when they have tapped "Scan a receipt" is asked in a context that explains itself.

Two of those are product decisions with measurable consequences, and the arithmetic is worth doing once.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Explain before you ask, not after. Android's shouldShowRequestPermissionRationale exists precisely for this: when it returns true, show your own explanation first, then request. Your screen can say why; the system dialog cannot.

Ask for the least you need. Coarse location instead of fine, "while using the app" instead of always, a single media type instead of the whole library. A narrower request is more likely to be granted, and the wider one remains available later.

Handle blocked as a first-class state, with a route to Settings and honest wording about what happened. This is the screen that gets built last and is the only recovery path that exists.

Re-check on focus. A user who goes to Settings, grants the permission, and returns should find the feature working. That means checking again when the screen regains focus — useFocusEffect from Part IV — rather than once on mount.

React — Try it Yourself
Loading editor…
Preview

That failure is worse than it looks, because the user did exactly what the screen asked and the screen did not notice. From Part II's lifecycle rules, a screen in a stack is not unmounted when the user leaves the app, so a mount-only check may not run again for the rest of the session.

In the real world

The commercial version of this mistake is asking for notifications on first launch, and it is common enough to have become a genre. The prompt fires before the user has seen anything of value, a large share refuse, and on iOS that refusal is permanent for the install — so the app has traded its entire notification channel for a dialog nobody was ready for. Teams then discover that the only remaining route is an in-app screen begging users to visit Settings, which converts at a fraction of the original prompt. The engineering version is subtler and just as common: a screen that treats check() returning false as "ask them", so a user in the blocked state taps a button, nothing visible happens, and they tap it again. There is no error to log and nothing in a crash report, because from the code's point of view the request resolved normally with a denial. The third pattern is a permission that was granted and then revoked — in Settings, or automatically by Android for an app that went unused — where the app checked once at install and cached the answer forever. All three are the same error in different clothes: treating a state machine as a fact.

Common Mistakes

  1. Modelling a permission as a boolean. There are at least four states and the UI needs three different renderings.
  2. Trusting check() alone. It returns a plain boolean and cannot distinguish never-asked from permanently blocked.
  3. Not recording whether you have asked. That one bit of your own storage is the only thing that resolves the ambiguity.
  4. Asking at launch. You get a fixed number of chances and this spends one at the least persuasive moment available.
  5. Skipping the rationale. shouldShowRequestPermissionRationale returning true is the system telling you to explain first.
  6. Treating blocked as denied. Calling request there shows nothing and resolves to a denial, so the button appears broken.
  7. Having no route to Settings. Linking.openSettings() is in core, it is the only path out of blocked, and the user cannot guess it.
  8. Showing a spinner while requesting. From the blocked state the promise resolves immediately with no dialog.
  9. Caching a grant forever. Permissions are revocable in Settings and auto-revoked for unused apps.
  10. Checking only on mount. A user returning from Settings needs the screen to re-check on focus.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
When should an app request a permission?
Model answer — At the moment the user does something that needs it, not at launch. Both platforms limit how many times the dialog can be shown — iOS once per permission per install, Android until the user has refused firmly enough — so every request spends a chance you cannot get back, and a request at launch spends it before the user knows what the app does. Asking when they have tapped "Scan a receipt" is asked in a context that explains itself, so it is far more likely to be granted, and if they refuse it you have still lost less because the feature was at least understood. The related habit is to ask for the narrowest version that works — coarse location rather than fine, while-using rather than always — because a smaller request is more likely to succeed and the larger one is still available later.
Intermediate
Why is a boolean the wrong model for a permission?
Model answer — Because there are four states and they need three different screens. Undetermined means asking will show a dialog. Denied means they refused but you may ask again, and on Android that is exactly when shouldShowRequestPermissionRationale returns true, telling you to explain first. Blocked means asking does nothing — the request resolves to a denial without showing anything — so the only route back is the system Settings app, which your code can take them to in one tap via Linking.openSettings() but cannot change on their behalf. Granted is the goal and is also revocable at any time. A boolean collapses denied and blocked together, which is how an "Ask again" button ends up on a screen where asking is a no-op: the user taps it, nothing happens, no error is logged, and from the code's point of view the promise resolved normally.
Advanced
Can you determine the full permission state from React Native's API?
Model answer — Not on Android, and the gap is precise. The native spec is four methods, and the two informational ones return booleans: checkPermission tells you granted or not, and shouldShowRequestPermissionRationale is true only after a refusal that can still be retried. Write out what those two return in each state and "never asked" and "permanently blocked" are identical — both are not-granted with no rationale needed — while the correct behaviour in those two states is opposite: ask them, versus send them to Settings. The only resolution is to persist one bit yourself recording whether you have ever requested it, and combine that with the two calls. I would argue that is deliberate rather than an oversight: the platform is intentionally unhelpful about how thoroughly a user has refused you, because an app that could measure that would optimise against it. The practical consequence is that permission handling cannot be got right from the API surface alone — it needs state the API has chosen not to keep.
Company lens — how the same topic gets asked
micro1Practical: build a camera screen that handles every permission state, and name them.
GoogleIts own platform — expect the runtime-permission model, rationale, and auto-revocation.
MetaThe API surface: what the four native methods return and what they cannot tell you.
AmazonProduct-flavoured: request timing, conversion, and recovering from a blocked permission.

Hands-on

Hands-on

Build the machine. Implement check and shouldShowRationale as the two native booleans behave in each state; inferState, which recovers the real state from those two booleans plus the one bit you persisted, and returns "ambiguous" when that bit is missing; transition, applying a request or a Settings visit with the arrows that actually exist; and uiFor, mapping a state to the screen it needs. The checks below run either way — a stub scores zero, so a passing run means the behaviour is real.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Summary

  • A permission is a four-state machine, not a boolean: undetermined, denied, blocked, granted.
  • The native API is four methods, and the two informational ones — checkPermission and shouldShowRequestPermissionRationale — both return plain booleans.
  • check() returns a boolean while request() returns three values, so the call you make most often is the lossy one.
  • "Never asked" and "permanently blocked" are indistinguishable from those two booleans, and the correct behaviour in each is opposite.
  • You must persist one bit yourself recording whether you have ever asked; that is the missing state, not a workaround.
  • request from the blocked state shows no dialog and resolves to a denial, which is why the button looks broken.
  • There is no transition from blocked back to denied. The only route is the Settings app — Linking.openSettings() takes them there, and only they can change it.
  • A grant is not permanent — revocable in Settings, and auto-revoked by Android for apps that go unused.
  • Ask at the moment of use, because every request spends one of a small fixed number of chances.
  • shouldShowRequestPermissionRationale returning true is an instruction: explain in your own words, then ask.

In the next chapter, Storage and Working Offline, we look at the other thing a phone has that a browser does not quite: a filesystem your app owns, a process that can be killed between two lines of code, and a network that is a sometimes rather than a given.

Editorial reflection

The ambiguity in this chapter is the most interesting thing I have found in a platform API for a while, because it is not an accident and it is not fixable. Android will tell you whether a permission is granted, and whether a refusal is still retryable, and it will not tell you whether you have ever asked — which leaves two states looking identical that call for opposite behaviour. My instinct was to file that as a gap. Sitting with it, I think it is a deliberate asymmetry of information, and the reasoning is sound: an app that could measure exactly how close it was to being permanently refused would optimise against that number, and the optimisation would be against the user. So the platform withholds it, and the cost lands on developers as a bit they must keep themselves. What I find worth generalising is the shape rather than the specifics. When an API seems to be missing something obvious, it is worth asking who would benefit from having it before assuming nobody thought of it — because sometimes the missing piece is missing on purpose, and the correct response is not a workaround but an acknowledgement that you are being asked to keep your own records.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.