Runtime Permissions Explained
ANDROID › System
Permissions interviews test three things at once: user experience, activity lifecycle, and privacy law, so interviewers use this topic to see how deep your Android knowledge really goes. The first thing to get straight is that not all permissions work the same way. **Normal** permissions, things like INTERNET or ACCESS_NETWORK_STATE, are granted automatically the moment your app is installed, with no prompt at all, because they carry little privacy risk. **Dangerous** permissions, things like CAMERA, READ_CONTACTS, or ACCESS_FINE_LOCATION, guard sensitive user data and have to be requested at runtime with a visible dialog, and you have to check them before every single use because the system is free to revoke them later. There is also a third, smaller category: **special app-access permissions**, like drawing over other apps or managing all files, which do not use a runtime dialog at all. You will get to those a bit later.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
Declaring a dangerous permission in the manifest is necessary but not sufficient. It only tells the system your app might ask for it later; the actual grant still has to come from the user at runtime.
Requesting a dangerous permission has one correct modern pattern: check first, then request through the AndroidX Activity Result API. ContextCompat.checkSelfPermission(context, permission) tells you the current state, returning PackageManager.PERMISSION_GRANTED or PERMISSION_DENIED. If it is not granted, you request it with a launcher you registered ahead of time, using registerForActivityResult and the ActivityResultContracts.RequestPermission contract, then trigger it later with launcher.launch(...).
val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) enableCamera() else handleDenied()
}
// later, e.g. on a button tap
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
That launcher has to be registered unconditionally, before the activity or fragment reaches the started state, typically as a property initializer, not inside a click handler. The older ActivityCompat.requestPermissions() plus onRequestPermissionsResult() pair is deprecated: you have to manage integer request codes yourself and it does not survive configuration changes cleanly.
When you need more than one permission at once, say camera and microphone for video capture, use ActivityResultContracts.RequestMultiplePermissions() instead. Its callback hands you a Map<String, Boolean> keyed by each permission string, so you can check every result individually rather than getting a single combined boolean.
Before you request, or right after a denial, shouldShowRequestPermissionRationale(permission) tells you whether the user needs an explanation first. It returns **true** once the user has denied the permission exactly one time without checking 'don't ask again'. That is your app's signal to show a short rationale, explaining why you need the permission, before you ask again.
when {
ContextCompat.checkSelfPermission(this, CAMERA) == PERMISSION_GRANTED ->
enableCamera()
shouldShowRequestPermissionRationale(CAMERA) ->
showRationaleThenRequest() // denied once already
else ->
requestPermissionLauncher.launch(CAMERA)
}
Showing a rationale at the right moment measurably improves grant rates, which is exactly why interviewers ask about it: it's UX and API knowledge in the same question.
Here's the trap interviewers love: shouldShowRequestPermissionRationale also returns **false** in a second, completely different situation, permanent denial, which happens after a second deny or after the user checks 'don't ask again'. So false alone is ambiguous: it means either 'never asked yet' or 'permanently denied', and the method cannot tell you which.
The fix is to remember it yourself, usually with a SharedPreferences flag you set the moment you first call launch().
val alreadyAsked = prefs.getBoolean("camera_asked", false)
val showRationale = shouldShowRequestPermissionRationale(CAMERA)
when {
isGranted -> enableCamera()
!showRationale && alreadyAsked -> openAppSettings() // permanently denied
else -> {
prefs.edit().putBoolean("camera_asked", true).apply()
requestPermissionLauncher.launch(CAMERA)
}
}
Without that flag, your app cannot tell a brand-new user from one who has already locked the permission out.
Once you've confirmed a permission is permanently denied, calling launch() again does nothing useful, the system dialog will not reappear, it just silently returns denied. The only way forward is sending the user to your app's own Settings page.
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
// Meanwhile, keep the app usable without the permission
Whatever you do, don't nag. Keep the feature gracefully degraded, and only offer the Settings shortcut when the user actually tries to use it again.
Not every sensitive capability goes through the dialog you've just learned. A small third category, **special app-access permissions**, like SYSTEM_ALERT_WINDOW for drawing over other apps, or MANAGE_EXTERNAL_STORAGE for unrestricted file access, skips the runtime permission dialog entirely. Instead, your app sends the user to a dedicated Settings screen with a specific intent, and you verify the result afterward by checking the system state directly.
if (!Settings.canDrawOverlays(this)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")
)
startActivity(intent)
}
// Verify later with Settings.canDrawOverlays(this)
These are rare and Google reviews apps that use them closely, so reach for one only when a feature genuinely cannot work without it.
Location brings two extra layers on top of everything so far, starting with precision. ACCESS_FINE_LOCATION is GPS-level precise, ACCESS_COARSE_LOCATION is approximate, roughly city-block accuracy. Since Android 12, the system dialog itself offers a Precise or Approximate toggle, so even when your app asks only for FINE, the user is free to grant just COARSE instead. The safe pattern is to request both together and branch on whatever actually came back.
val locationLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val fine = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarse = grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true
when {
fine -> usePreciseLocation()
coarse -> useApproximateLocation()
else -> handleDenied()
}
}
locationLauncher.launch(arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
))
If your feature can work with a rough location, coarse-only is a perfectly good outcome, not a failure case.
The second location layer is background access. From Android 10 onward, you cannot request ACCESS_BACKGROUND_LOCATION in the same dialog as foreground location at all, the system simply will not let you bundle them. You have to request foreground location first, wait for it to be granted, and only then request background as its own separate step. On Android 11 and later, that background request does not even show an in-app dialog, it takes the user straight to a Settings screen where they choose 'Allow all the time'.
val fgLauncher = registerForActivityResult(RequestPermission()) { granted ->
if (granted) requestBackgroundLocation()
}
val bgLauncher = registerForActivityResult(RequestPermission()) { /* verify grant */ }
fun requestBackgroundLocation() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+: opens Settings, not an in-app dialog
bgLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
fgLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
Asking for background access before foreground is granted is not just bad practice, the system will reject it outright.
Two more ways a grant can quietly disappear without the user ever touching your app again. First, since Android 11, the permission dialog for location, camera, and microphone offers 'Only this time'. Choosing it grants access only while the app is actively in use, once your app has spent a while in the background, the system revokes it automatically, and the next time the feature runs you'll be prompted again. If the user explicitly revokes a permission from system settings instead, the app's process is killed outright. A full 'Allow all the time' or ordinary foreground grant is not affected by backgrounding at all, only the one-time grant is that fragile.
override fun onResume() {
super.onResume()
if (ContextCompat.checkSelfPermission(this, CAMERA) != PERMISSION_GRANTED) {
disableCameraFeature() // a one-time grant may have been revoked
}
}
Second, Android 11 also introduced permission auto-reset: if a user does not open your app for several months, the system strips its runtime permissions entirely and hibernates the app, regardless of how the permission was originally granted. Both mechanisms teach the same lesson: never cache a granted permission as true forever. Re-check with checkSelfPermission right before a permission-gated feature runs, not just once at launch.
Storage went through the biggest privacy overhaul of any permission area. Scoped storage, enforced from Android 10 or 11, gives every app its own private external directory with no permission needed at all, plus the MediaStore API for shared media. That made the old broad READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE permissions largely obsolete; Android 13 replaced them with granular READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, and READ_MEDIA_AUDIO.
Better still, the Photo Picker lets a user hand your app one specific photo or video through a system UI, with **no permission at all**:
val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) displayPhoto(uri)
}
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
Android 14 went further again, adding a middle ground to the media dialog itself: instead of granting full READ_MEDIA_IMAGES, the user can choose 'Select photos', picking specific items and granting READ_MEDIA_VISUAL_USER_SELECTED instead. Your app then sees only what the user hand-picked, not their whole library.
Android 13 turned an action you'd never have expected into a dangerous permission: posting a notification. POST_NOTIFICATIONS guards exactly that, and any app targeting API 33 or above must request and be granted it before the system will display a single notification. On older API levels, notifications still work with no prompt at all, so this check only matters once your targetSdkVersion reaches 33.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
Miss this one and your notifications don't crash, they just silently never appear, which makes it a nasty bug to track down without knowing the permission exists.
One last piece of terminology trips people up: permission **groups**. Android bundles logically related permissions, like the various location permissions, into a group so the system can present one clean dialog instead of several. It's tempting to assume that granting one permission grants its group-mates too, but since Android 10 the system tracks the grant state of every permission individually, not per group, and Google explicitly warns that which permissions belong to which group can change between OS releases. Never infer a grant for a permission you actually depend on, always check that exact permission with checkSelfPermission, even if a sibling in the same group was already approved.
Zoom out and every permission flow you've just learned collapses into the same decision tree. Check checkSelfPermission first: granted, proceed. Not granted, check shouldShowRequestPermissionRationale: true means explain, then request; false is ambiguous between 'never asked' and 'permanently denied', so lean on your own persisted flag to tell them apart, and once you know it's permanent, stop requesting and route to Settings instead. That same shape, check, explain, request, fall back to Settings, reappears everywhere: in the special app-access permissions that skip the dialog entirely, in background location's forced two-step request, and in one-time grants that quietly expire the moment your app is backgrounded too long.
What ties all of it together, and what's worth saying out loud in an interview, is that every change Android has made in this area for the last several years points the same direction: minimize what an app can see, minimize how long it can see it, and give the user a cheap way to say 'just this once' or 'only the thing I picked' instead of an all-or-nothing grant. Scoped storage, the Photo Picker, coarse-only location, one-time grants, and auto-reset are not five unrelated features, they're one privacy philosophy applied to five different surfaces.