RizTech Academy logo
RizTech Academy
Permissions and Device FeaturesLesson 1 of 435 min

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:

  1. 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.
  2. 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.
  3. 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

  1. Add ui/permissions/RuntimePermission.kt, its test, and refusedPermissions to UserPreferencesRepository and its fake. Run the tests.
  2. In a scratch copy with an applicationIdSuffix, add a log line to onFeatureTap and to the launcher's callback printing granted, rationale and the time taken. Reproduce the table above.
  3. After each "Don't allow", run dumpsys package and read the flags. Then unblock it with pm clear-permission-flags and tap the button again.
  4. Choose "Only this time", go Home, wait a minute, come back and tap "Update". Write down what happened, and read dumpsys package again.
  5. Break it on purpose: make permissionStatus ignore refusedBefore, then press Back on the first dialog. Read the card, and the flags.
  6. Harder: module 10's Alerts screen asks for POST_NOTIFICATIONS its own way. Move it onto rememberRuntimePermission, keep its card, and decide what a refused-once user should see when she saves her second alert. Write the test first.

Official documentation

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