The first time it happened to me, I thought it was a bug. The app asked the user for the location permission. It was accepted. The system returned PERMISSION_GRANTED. Right after that, the location provider returned nothing; no error, no exception, no timeout. No location at all. On the development Pixel it worked perfectly, and on the client's Xiaomi it never worked.
That is the moment you discover that the Android permission model you read about in the documentation is not the whole story. That documentation describes AOSP, the Android Open Source Project: the base system Google publishes before Google and every manufacturer start stacking things on top of it. Each manufacturer writes a part of it (a part that is not documented anywhere), and changes it with every update.
1. The contract AOSP promises
The AOSP model is reasonable and well specified. It splits permissions into two big groups:
- Normal: granted at install time. Declaring them in the
manifestis enough. - Special: these are the dangerous ones. They require explicit confirmation from the user, usually at runtime.
For the second group, you have to check the current state with checkSelfPermission, launch the dialog to confirm the permission with requestPermissions, and receive the result (decided by the user) in onRequestPermissionsResult. In other words, you build a state machine with: granted, denied, and permanently denied (there is a variable called shouldShowRequestPermissionRationale that returns false after a denial).
Over the years AOSP has been updated, adding nuances that aim to give the user more control. Some examples: one time permissions that expire when the app closes, automatic permission reset for apps that have not been opened in months, the split between foreground and background location that forces two separate requests...
This implementation is predictable. It is documented, it has a stable API, and most importantly, it is honest. When the system says you have a permission, you have it.
2. The layer nobody documents
As I mentioned at the beginning, what runs on a real phone is not AOSP. It is a set of three stacked layers, and only the first one is documented:
- AOSP, the base system. Kernel, framework, APIs, the official permission manager. It is what defines the contract from the previous section.
- GMS (Google Mobile Services), Google's proprietary layer that manufacturers license: Play Store, Play Services, Maps. It is not part of AOSP, and it is the reason Android phones without the Play Store exist.
- The manufacturer layer: MIUI and HyperOS on Xiaomi, One UI on Samsung, EMUI and HarmonyOS on Huawei, ColorOS on Oppo, Funtouch on Vivo.
"Pure AOSP" is what an emulator runs and, with GMS on top and not much else, what a Pixel runs. It is also, almost always, the only place where we developers test the app we are building. And the problem is that the manufacturer layer is usually the one that decides what a granted permission really means.
That third layer is not just visual. It includes its own permission manager, its own autostart manager and its own battery manager. Every one of those managers can get between your app and the system.
The clearest case is usually the battery one. The manufacturer adds an aggressive policy about what apps can do in the background in order to sell battery life and power savings. For that to be effective, it has to override what AOSP granted. The result is a second list of "permissions" or switches, parallel to the standard permission list, that the user does not know exists and that, generally, your app cannot query through any public API.
The specific mechanisms vary by brand, but they can be grouped into three families:
- Autostart lists. If the app is not on the list, it does not receive the broadcasts or the alarms that would wake it up.
- Custom battery managers. Independent from AOSP's
Dozeand from the standard battery optimization switch. AOSP can report that the app is exempt from optimization while the manufacturer adds it to a blacklist. - Parallel permission dialogs. Permissions that either do not exist in AOSP or do not even need the user to accept them, and that the manufacturer layer marks as dangerous with its own dialog, outside the
requestPermissionscycle.
If you need the exact detail per brand and version, dontkillmyapp.com maintains a fairly reliable community catalog of what each layer does and which settings path to follow on each one. It gets updated when manufacturers change things. And they change them often.
3. Why checkSelfPermission lies
The underlying problem is one of API design. It has nothing to do with a specific brand. checkSelfPermission answers the question "is this permission granted in the system permission registry?". But what you actually need to know is: "can I perform the operation I asked this permission for?". On pure AOSP both questions have the same answer. With a manufacturer layer in between, they do not.
Given that, the failures I ran into while building the electric vehicle telemetry app are these:
- Permission granted with an empty result. The registry says the permission is granted, but the query to the provider says otherwise. As a result, the user gets empty contacts on a phone with three hundred contacts. This one is tricky, because an empty result is not an error, it is a legitimate result: you cannot tell "you have no contacts" apart from "the manufacturer is filtering the query and showing you 0 contacts".
- Request with no callback. You call
requestPermissions, the manufacturer decides to deny without showing anything, andonRequestPermissionsResultis never invoked. Wrapping that call in a promise or in a suspended coroutine leaves it hanging forever. In React Native this shows up as a button that stops responding and never comes back. - Silent revocation. The permission is granted, it works for a while, and when you come back to the app days later it is gone. There was no update, and the user did not touch the settings. The manufacturer's manager decided after some time that the app did not need it.
- Background work that never happens. It does not break the permission itself, it breaks everything that depended on it. The foreground service gets killed with its notification up, the alarm does not fire, the scheduled job does not run.
The state of a permission is not a property of the system that you can query. It is a hypothesis that has to be verified.
4. Check capability, not permission
The solution is to stop asking about the permission and start checking the capability. Instead of blindly trusting the registry, you run the smallest and cheapest possible operation, the one that proves the functionality is actually available.
Following the contacts example, a query with limit 1 against the provider. In a notifications case, checking the channel on top of the permission. Each of these tests is an operation you already know how to do, and it returns something far more useful than a true/false.
The key is in the return type. A permission does not have two states, it has four, and they need to be modeled and handled:
1export type PermissionOutcome =
2 | { status: "usable" }
3 | { status: "grantedButUnusable"; reason: string }
4 | { status: "denied" }
5 | { status: "blocked" }
6
7export type CapabilityProbe = () => Promise<boolean>
8
9// The consumer can tell apart the two cases that used to be conflated.
10export function messageFor(outcome: PermissionOutcome): string {
11 switch (outcome.status) {
12 case "usable":
13 return ""
14 case "denied":
15 return "We need this permission to continue."
16 case "blocked":
17 return "Enable the permission from the system settings."
18 case "grantedButUnusable":
19 // The case the AOSP API has no name for, and 90% of the tickets.
20 return "The permission is granted but your phone is blocking it."
21 }
22}It is worth separating Denied from Blocked and Usable from GrantedButUnusable. That way the error message shown to the user (and the one the developer gets) is more expressive and allows for better guidance. Having something fail and the user see a "grant the permission" message when they already granted it is a very bad experience. That is the problem with trusting checkSelfPermission.
The probe has to be cheap and silent, because it is going to run every time a screen starts up. If opening the camera to check it turns on the LED or takes half a second, cache the result for the session and invalidate it when the app comes back from the background, which is exactly the moment when the user could have changed something in the settings.
5. Your own manager
With the four states and the probes, your own permission manager almost writes itself. Its job is to centralize permission requests and add timeouts (so that if the dialog does not respond within N seconds it gets marked as blocked, which avoids waiting for a callback that is never coming), follow up probes, and practical telemetry.
1import { NativeModules } from "react-native"
2
3const { PermissionManager } = NativeModules
4
5// A single entry point for the whole app. Nobody calls
6// PermissionsAndroid directly.
7export async function ensurePermission(
8 permission: AppPermission,
9): Promise<PermissionOutcome> {
10 try {
11 return await PermissionManager.ensure(permission)
12 } catch (error) {
13 // A native bridge failure must not take the screen down.
14 return { status: "grantedButUnusable", reason: "bridge_error" }
15 }
16}
17
18export async function withCamera<T>(action: () => Promise<T>): Promise<T | null> {
19 const outcome = await ensurePermission("camera")
20
21 if (outcome.status !== "usable") {
22 showPermissionSheet(outcome)
23 return null
24 }
25
26 return action()
27}Concentrating everything in one place has a side effect worth more than the manager itself: telemetry. When every permission resolution emits an event tagged with the manufacturer, in two weeks you have real data on which brand and permission combinations fail across your user base. It stops being Stack Overflow folklore and becomes a table you can sort.
6. Per-brand quirks without hardcoding versions
Once we know MIUI behaves differently, the easiest thing is to write an if on Build.MANUFACTURER and drop that manufacturer's special case in there. But this code ages terribly. The more cases, the more ifs and the more logic you need.
And there are several reasons for this to fail:
- The names of the internal settings activities change between versions and disappear without warning. An
Intentpointing at a specific manufacturer screen is a potential exception in the long run. - The value of
MANUFACTURERis not reliable as an identity. There are devices from different brands with the same layer underneath. And devices from the same brand with different layers. - The behavior you want to detect does not depend on the brand, but on a setting the user can change at any time.
The alternative is to ask the system. Before launching a manufacturer specific settings screen, it is better to check that the screen exists on that manufacturer. AOSP can serve as the generic screen:
1import { Linking } from "react-native"
2
3// Several missed cycles in a row are evidence enough.
4const MISSED_RUNS_THRESHOLD = 3
5
6// The generic screen works on every device.
7// Manufacturer specific ones are a bonus, never the main path.
8export async function openPermissionSettings(): Promise<void> {
9 await Linking.openSettings()
10}
11
12/**
13 * Autostart cannot be queried through an API, so it gets inferred:
14 * if the periodic work has not run in far longer than it was
15 * scheduled for, something is killing it.
16 */
17function looksThrottled(log: BackgroundWorkLog, intervalMs: number): boolean {
18 if (log.lastSuccessfulRun === null) return false
19 return Date.now() - log.lastSuccessfulRun > intervalMs * MISSED_RUNS_THRESHOLD
20}
21
22// The warning only shows up if there is evidence of throttling,
23// not because the phone happens to be from a particular brand.
24export function shouldWarnAboutBackground(
25 log: BackgroundWorkLog,
26 intervalMs: number,
27): boolean {
28 return looksThrottled(log, intervalMs) && !log.userDismissedWarning
29}Knowing the user is on a Xiaomi lets you spell the settings out for them, with more direct paths. It sounds subtle, but that difference shows up in how many people complete the process. It is a copy decision, not a logic one.
Detection has to be passive: record when your periodic work last ran and compare it against the expected interval. If it piles up several missed cycles, you have evidence enough to warn the user, and you did not spend any battery getting it.
7. When the background is the product
Everything above assumes there is a moment when the user has the app open. That is where you ask for the permission, where you check the capability, where you show the error message if something went wrong. But there are products where that moment does not exist.
On the electric vehicle telemetry app that was exactly the requirement. The app connects over BLE to an OBD-II reader, sends it UDS commands and records the vehicle's trip. The user only has to get in the car and drive. They do not have to open the app. They do not have to press a button to start recording the trip. They do not need to remember the app is on their phone at all. It may have been closed for days.
That puts the center of the product on background operation. It is not an extra feature to make things convenient. It is the app's reason to exist. And it breaks every rule and every escape hatch discussed in this article. When it fails, there is no way to show it to the user.
The hardest detail to explain is that a trip where something goes wrong is a lost trip. If the app does not wake up the moment the car starts moving, those kilometers, that data, do not exist. There is no "open the app and it syncs", because the data was only available while the car was moving. The OBD II needs the connection to send the data. And with no app running, there is no connection. Compared to this, a notification that does not sound when it should is an anecdote.
The solution Android offers for these cases is the foreground service with a persistent notification. That way the system has an explicit reason to keep the process alive while the trip is active. And the part that has to survive the app being closed cannot live on the JavaScript main thread, so the BLE connection has to sit in a native module in Kotlin (or, on iOS, which is a completely different world, in Swift). That was, by far, the hardest technical challenge of the project.
There is also a decision to make that is design and not code. If the app depends on autostart, the right place to ask for it is the onboarding and not an error message after the first failure. When the warning arrives later, the user has already lost data (a whole trip that never got recorded) and has already formed an opinion about whether the app works. An installation step, on the other hand, lands at the only moment when someone is willing to go into the system settings and follow the steps that guarantee the app works properly.
Closing the app and killing the process are not the same thing, and each manufacturer layer decides where the difference lies. Swiping the app out of recents may stop the whole process or may let it keep making calls. If the product depends on the background, this is the first thing to test on a device from each brand, and it cannot be deduced from reading the documentation.
Unfortunately, you do not always win. There are brands and manufacturers where no amount of engineering substitutes for the user flipping the switch buried in the settings. Once you reach that point, what is left is no longer technical.
8. Degrading with dignity
After all of this there is still a percentage of cases that cannot be fixed from the app. The manufacturer has decided that a background job does not run. There is no API that solves it. That percentage is small but it is not zero, and it deserves a plan.
The plan is to design the functionality so that the part depending on the manufacturer is a bonus and not the foundation. If scheduled local notifications may not fire, have the state sync when the app opens too. If periodic work may not run, add a manual refresh button that does the same thing. If background location may die, make sure the core functionality still makes sense with foreground location.
This sounds like resignation and it is actually good architecture. An app that assumes the background is best effort instead of a guarantee is an app that also works well in airplane mode, with the battery at five percent, and on the next version of Android with new settings.
The manufacturers' permission hell is just the extreme case of something that was already true.
And for the percentage that still falls outside, the best tool is honesty. A message that says exactly what is happening, with the name of the setting exactly as it appears on that phone, and a button that takes the user as close to it as possible. It does not fix the problem, but it turns a support ticket into something the user can solve on their own.
Writing code for Android is not writing code for one operating system. It is writing code for several dozen operating systems that share an API and disagree about what that API means.
