The permission model, and asking at the right moment
Module 10 asked for one permission, once: when Priya saved her first price alert, Mandi Bhav asked to send notifications, and if she said no, a card told her where to turn them on. That was the forgiving case. This module adds the nearest market, which needs her location, and a photo of her sale slip, and a farmer who has said no once may say no again. Android tells an app very little about what the user has already decided, and the little it tells you means different things at different moments. This lesson measures exactly what it says at each step, on the course emulator, and builds the one piece of code every permission in the app will go through.
Three kinds of permission
Every permission an app uses is declared in its manifest. What happens next depends on the kind.
| Kind | Examples | How it is granted | What the user sees |
|---|---|---|---|
| Install-time ("normal") | INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK |
At install, always | Nothing; listed on the Play page |
| Runtime ("dangerous") | ACCESS_COARSE_LOCATION, CAMERA, POST_NOTIFICATIONS, READ_MEDIA_IMAGES |
Only when the app asks and the user agrees | A system dialog the app cannot change |
| Special | SCHEDULE_EXACT_ALARM, SYSTEM_ALERT_WINDOW |
The user turns it on in a Settings page | Nothing, until the app sends them there |
You can read the state of every one from adb. Mandi Bhav after a fresh install, with this module's code:
adb shell dumpsys package com.riztechacademy.mandibhav
install permissions:
android.permission.FOREGROUND_SERVICE: granted=true
android.permission.RECEIVE_BOOT_COMPLETED: granted=true
android.permission.INTERNET: granted=true
android.permission.ACCESS_NETWORK_STATE: granted=true
com.riztechacademy.mandibhav.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION: granted=true
android.permission.WAKE_LOCK: granted=true
runtime permissions:
android.permission.POST_NOTIFICATIONS: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.ACCESS_COARSE_LOCATION: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
Four of those install permissions are not in Mandi Bhav's manifest at all. FOREGROUND_SERVICE, RECEIVE_BOOT_COMPLETED and WAKE_LOCK came from WorkManager's manifest, and the long one is androidx.core's own, for receivers it registers; the build merged them in. Every library can add permissions, and they reach the user's phone under your app's name. Read this list after adding any dependency.
The best permission is one you never ask for. The table at the end of the next lesson shows that taking a photo and choosing one need no permission at all in Mandi Bhav, and sharing and opening a map need none either. Of everything in this module, only the nearest market asks, and it asks for the smallest thing that does the job: approximate location, not precise (lesson 3 says why).
When to ask
Android's own guidance comes down to three rules, and module 10 already followed them for notifications:
- Ask from the button that needs it. Not at launch, not on a welcome screen. "Use my location" on the Markets tab is the only place Mandi Bhav asks for location; until Priya taps it, nothing about location happens.
- Explain before the second request, never before the first. The system dialog says what the app wants, not why. If she has refused once, she gets Mandi Bhav's own explanation, with a "Not now" button, before the system asks again.
- Accept the answer. The Markets list works without a location: it stays in name order, and the card says what she is missing in one sentence. No full-screen warning, no asking again on every visit.
The system dialog itself cannot be changed: its words, its buttons, its picture of a map. What you control is the moment and the words around it.
What Android tells you, measured
An app learns two things, both from androidx.core:
ContextCompat.checkSelfPermission(context, permission): granted or not, now.ActivityCompat.shouldShowRequestPermissionRationale(activity, permission): whether the app "should show a rationale" before asking.
To see what the second one really means, a scratch copy of Mandi Bhav (installed as com.riztechacademy.mandibhav.m11, so it never replaces the real app) logs both on every tap of "Use my location", and how long the system took to answer. Priya refuses, is shown the explanation, refuses again, closes the app and tries a third time:
I M11Perm : tap granted=false rationale=false
I M11Perm : result granted=false rationale=true after 3687 ms
I M11Perm : tap granted=false rationale=true
I M11Perm : result granted=false rationale=false after 3651 ms
I M11Perm : tap granted=false rationale=false
I M11Perm : result granted=false rationale=false after 124 ms
| Step | Granted | Rationale | What happened |
|---|---|---|---|
| Before the first request | false | false | The dialog showed |
| After one "Don't allow" | false | true | The app's explanation, then the dialog again |
| After the second "Don't allow" | false | false | Blocked |
| Third request | false | false | No dialog at all; "denied" in 124 ms |
And the package's flags after the second refusal:
android.permission.ACCESS_COARSE_LOCATION: granted=false, flags=[ USER_SET|USER_FIXED|USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
USER_FIXED is "don't ask again". Since Android 11 there is no checkbox for it: refusing twice is the same as "never ask me again", and from then on launch() returns "denied" without showing anything. The only way back is the app's page in Settings.
Look at the rationale column. It is false before the first request and false after the second refusal. From the two facts Android gives, a fresh install and a blocked permission look identical.
The trap: closing the dialog is not refusing
The first version of Mandi Bhav's helper worked out the state from those two facts at the moment of the answer: denied with a rationale meant "refused once", denied without one meant "blocked". It passed the refuse-twice test above. Then, on a fresh install, Priya tapped "Use my location" and pressed Back instead of choosing:
I M11Perm : tap granted=false rationale=false refusedBefore=false status=NotAsked
I M11Perm : result granted=false rationale=false after 1631 ms
Not granted, no rationale: the helper said blocked, and the card told her "Location is not allowed... allow Mandi Bhav to use your location in Settings". But dumpsys disagreed:
android.permission.ACCESS_COARSE_LOCATION: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
No USER_SET, no USER_FIXED: Android had counted nothing, and the next request would have shown the dialog. The app sent her to Settings for a question she had not answered.
The fix is the one fact Android does not give: whether the user has ever refused. Mandi Bhav writes it down in DataStore, next to the home market, the moment it sees a rationale (which only appears after a real refusal), and clears it when the permission is granted. With it, the four states are a pure function. ui/permissions/RuntimePermission.kt:
enum class PermissionStatus { Granted, NotAsked, Refused, Blocked }
fun permissionStatus(granted: Boolean, showRationale: Boolean, refusedBefore: Boolean): PermissionStatus = when {
granted -> PermissionStatus.Granted
showRationale -> PermissionStatus.Refused
refusedBefore -> PermissionStatus.Blocked
else -> PermissionStatus.NotAsked
}
enum class PermissionAction { Proceed, Ask, ExplainFirst, OpenSettings }
fun actionFor(status: PermissionStatus): PermissionAction = when (status) {
PermissionStatus.Granted -> PermissionAction.Proceed
PermissionStatus.NotAsked -> PermissionAction.Ask
PermissionStatus.Refused -> PermissionAction.ExplainFirst
PermissionStatus.Blocked -> PermissionAction.OpenSettings
}
RuntimePermissionTest has one test per measured row, including "dismissing the first dialog is not a refusal". With the fix, the same Back press left the card saying "Nearest market... Use my location", and the next tap showed the system dialog again.
Android's documentation warns against storing permission state in DataStore, and it is right: a stored "granted" goes stale the moment the user changes it in Settings. Mandi Bhav stores a hint, never the grant: every decision still reads checkSelfPermission and the rationale fresh. The hint has a cost too. If Android resets the permission by itself (it does that for apps unused for a few months, from Android 11), the hint still says "refused", and the card sends Priya to Settings when the dialog would have worked. That costs her one extra screen, never a dead end, and a grant clears the hint.
The helper
rememberRuntimePermission wraps all of this for a composable. The parts worth reading:
@Composable
fun rememberRuntimePermission(
permission: String,
refusedBefore: Boolean,
onRefusedChange: (Boolean) -> Unit,
onGranted: () -> Unit,
): RuntimePermissionState {
// ...
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
recordWhatTheSystemShows()
reads++
if (isGranted) currentOnGranted()
}
LifecycleResumeEffect(permission) {
recordWhatTheSystemShows()
reads++
if (sentToSettings && context.isGranted(permission)) {
// Back from Settings, where the user turned it on: finish what they asked for.
sentToSettings = false
currentOnGranted()
}
onPauseOrDispose { }
}
// ...
}
recordWhatTheSystemShows is the only place the hint is written, and it runs after an answer or on resume, never during composition, because composition can run many times and must not have side effects. reads is a counter bumped to make the status be read from the system again. The explanation's visibility is in rememberSaveable, so rotating the phone while it shows keeps it showing.
On the Markets tab the route connects it to the ViewModel, which keeps the hint in UserPreferencesRepository:
val location = rememberRuntimePermission(
permission = Manifest.permission.ACCESS_COARSE_LOCATION,
refusedBefore = refusedBefore,
onRefusedChange = viewModel::setLocationRefused,
onGranted = viewModel::findNearest,
)
On the emulator, the whole path with the real app: "Use my location", "Don't allow", the card's one sentence; "Use my location" again, Mandi Bhav's "Find the nearest market?" with "Continue" and "Not now"; "Continue", "Don't allow"; the card now said "Location is not allowed" with "Open Settings". In Settings, Permissions, Location, "Allow only while using the app", Back three times, and the card was already saying "Finding where you are…": sentToSettings finished what she had asked for. In the scratch build, blocked and then closed with am force-stop and reopened, the blocked card came straight back, because the hint survived the process.
Revoked while the app is in the background
A user can take a permission away in Settings at any time, including while your app sits in Recents. With the scratch app open on the Markets tab, Home, then:
adb shell pm revoke com.riztechacademy.mandibhav.m11 android.permission.ACCESS_COARSE_LOCATION
I ActivityManager: Killing 12486:com.riztechacademy.mandibhav.m11/u0a240 (adj 700): permissions revoked
Android kills the process when a runtime permission is revoked. Reopened from Recents, it was a new process (pid 12597), back on the Markets tab from its saved state, with the card in its first state. Nothing in the app had to notice the revocation, because nothing remembered the grant: the helper reads it on every resume, and the location source checks it again before every fix. An app that cached "we have location" in a field would have been fine too, here, because the process died with the field; the bug appears with anything that outlives the process, which is exactly why the hint is the only thing stored.
Commands for testing permissions
| Command | What it does |
|---|---|
adb shell pm grant <package> <permission> |
Grants, as if the user allowed |
adb shell pm revoke <package> <permission> |
Revokes; kills the process if it is running |
adb shell pm reset-permissions -p <package> |
Every runtime permission back to never-asked |
adb shell pm clear-permission-flags <package> <permission> user-set user-fixed |
Unblocks one permission without granting it |
adb shell dumpsys package <package> |
Every permission, granted or not, with its flags |
adb install -g <apk> |
Installs with every runtime permission granted (for tests, never for checking your dialogs) |
Two more behaviours to know, both from Android 11. "Only this time" appears in the dialog for location, microphone and camera: the grant lasts while the app is in use and a short while after it goes to the background, then disappears, and the next tap asks again. Auto-reset: an app unused for a few months has its runtime permissions reset. Both are why "check every time" is the rule, not a precaution.
Check your work
What shouldShowRequestPermissionRationale returned at each step. False before the first request, true after one refusal, false after the second; and the third request returned "denied" in 124 ms with no dialog.
Why the app cannot tell "never asked" from "blocked" on its own. Both are "not granted, no rationale". The difference is whether the user has refused before, which Android does not report, so the app remembers it.
What pressing Back on the first dialog does. Returns "not granted, no rationale", but counts nothing: no USER_SET flag, and the dialog shows again next time. Treating it as blocked sent the user to Settings for nothing.
What happens when a permission is revoked while the app is in the background. The process is killed ("permissions revoked"); the app comes back as a new process with its saved state, and must read the permission again rather than trust anything it remembered.
Practice 3. After one "Don't allow", the flags are USER_SET and the rationale is true; after the second, USER_SET|USER_FIXED and the rationale is false.
Practice
- Add
ui/permissions/RuntimePermission.kt, its test, andrefusedPermissionstoUserPreferencesRepositoryand its fake. Run the tests. - In a scratch copy with an
applicationIdSuffix, add a log line toonFeatureTapand to the launcher's callback printing granted, rationale and the time taken. Reproduce the table above. - After each "Don't allow", run
dumpsys packageand read the flags. Then unblock it withpm clear-permission-flagsand tap the button again. - Choose "Only this time", go Home, wait a minute, come back and tap "Update". Write down what happened, and read
dumpsys packageagain. - Break it on purpose: make
permissionStatusignorerefusedBefore, then press Back on the first dialog. Read the card, and the flags. - Harder: module 10's Alerts screen asks for
POST_NOTIFICATIONSits own way. Move it ontorememberRuntimePermission, keep its card, and decide what a refused-once user should see when she saves her second alert. Write the test first.
Official documentation
- Request runtime permissions — The workflow, refusing twice as permanent denial, the
USER_FIXEDflag, one-time permissions and auto-reset. - Permissions on Android — Install-time, runtime and special permissions, and permission groups.
- App permissions best practices — Google's guidance on asking for less, and on what to do instead.
- Request special permissions — The Settings-page permissions this lesson only names.
Next: camera, photos and the media picker, where the right answer is to ask for no permission at all.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship