Runtime permissions are one of those things that should be simple but somehow always trip people up. The user says "deny", and now your app crashes. You request the permission again and Android silently ignores it. You call onRequestPermissionsResult and... wait, that's deprecated now?
This guide covers the modern way to handle runtime permissions in Android using ActivityResultContracts — the replacement for the old deprecated approach. We'll cover single permissions, multiple permissions, rationale dialogs, "Don't Ask Again" handling, and the permission changes introduced in Android 10, 11, and 13 that catch developers off guard.
Normal vs Dangerous Permissions
Not all permissions require a runtime dialog. Android splits them into two categories:
| Type | How It Works | Examples |
|---|---|---|
| Normal | Granted automatically — no dialog shown | INTERNET, VIBRATE, NFC |
| Dangerous | Requires user approval at runtime | CAMERA, LOCATION, CONTACTS, MICROPHONE |
Only dangerous permissions need runtime handling. Normal permissions just need to be declared in AndroidManifest.xml and are granted automatically.
Step 1 — Declare in AndroidManifest.xml
Always declare the permission in the manifest first — even for runtime permissions. The dialog won't appear without this:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Normal permissions — auto-granted, no dialog -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Dangerous permissions — require runtime request -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Android 13+ media permissions (replaces READ_EXTERNAL_STORAGE) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<application ... />
</manifest>
Step 2 — Request a Single Permission
Use ActivityResultContracts.RequestPermission() — the modern replacement for the deprecated onRequestPermissionsResult(). Register the launcher at class level, call it when you need the permission:
import androidx.activity.result.contract.ActivityResultContracts
class CameraActivity : AppCompatActivity() {
// Register launcher at class level — not inside onCreate
private val requestCameraPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// Permission granted — proceed with camera
openCamera()
} else {
// Permission denied — check if permanently denied
handlePermissionDenied()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_camera)
binding.btnOpenCamera.setOnClickListener {
checkAndRequestCameraPermission()
}
}
private fun checkAndRequestCameraPermission() {
when {
// Already granted — proceed immediately
ContextCompat.checkSelfPermission(
this, Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED -> {
openCamera()
}
// Should show rationale — user denied once before
shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
showRationaleDialog(
title = "Camera Permission Required",
message = "This feature needs camera access to take photos. Please grant the permission.",
onConfirm = { requestCameraPermission.launch(Manifest.permission.CAMERA) }
)
}
// First time or permanently denied — just request
else -> {
requestCameraPermission.launch(Manifest.permission.CAMERA)
}
}
}
private fun handlePermissionDenied() {
if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
// User tapped "Don't Ask Again" — must redirect to Settings
showSettingsDialog()
} else {
// Denied but can ask again
Toast.makeText(this, "Camera permission is required", Toast.LENGTH_SHORT).show()
}
}
private fun openCamera() {
// Your camera code here
}
}
registerForActivityResult() must be called before onStart() — calling it inside a click listener or later causes an IllegalStateException. Always declare it as a class property.
Request Multiple Permissions at Once
Use ActivityResultContracts.RequestMultiplePermissions() when you need several permissions together — for example a video call feature that needs both camera and microphone:
class VideoCallActivity : AppCompatActivity() {
private val requestMultiplePermissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val cameraGranted = permissions[Manifest.permission.CAMERA] == true
val micGranted = permissions[Manifest.permission.RECORD_AUDIO] == true
when {
cameraGranted && micGranted -> {
startVideoCall()
}
!cameraGranted && !micGranted -> {
showToast("Camera and microphone permissions are required")
}
!cameraGranted -> {
showToast("Camera permission is required for video calls")
}
!micGranted -> {
showToast("Microphone permission is required for audio")
}
}
}
private fun checkAndRequestPermissions() {
val permissionsToRequest = mutableListOf<String>()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
permissionsToRequest.add(Manifest.permission.CAMERA)
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
permissionsToRequest.add(Manifest.permission.RECORD_AUDIO)
}
if (permissionsToRequest.isEmpty()) {
// All permissions already granted
startVideoCall()
} else {
requestMultiplePermissions.launch(permissionsToRequest.toTypedArray())
}
}
}
Rationale Dialog and "Don't Ask Again" Handling
The trickiest part of permissions. Here's the full flow and what each state means:
| User Action | shouldShowRationale() | What to do |
|---|---|---|
| First time requesting | false |
Request directly |
| Denied once | true |
Show rationale dialog, then request |
| Denied + "Don't Ask Again" | false |
Show settings dialog, redirect to app settings |
// Rationale dialog — explain why you need the permission
private fun showRationaleDialog(
title: String,
message: String,
onConfirm: () -> Unit
) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("Grant") { dialog, _ ->
dialog.dismiss()
onConfirm()
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
.show()
}
// Settings dialog — when permission is permanently denied
private fun showSettingsDialog() {
AlertDialog.Builder(this)
.setTitle("Permission Required")
.setMessage("This permission was permanently denied. Please enable it in app Settings to continue.")
.setPositiveButton("Open Settings") { dialog, _ ->
dialog.dismiss()
// Open app settings
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
.show()
}
Requesting Permissions in a Fragment
class LocationFragment : Fragment() {
private val requestLocationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
fetchUserLocation()
} else {
handleDenied()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnGetLocation.setOnClickListener {
checkLocationPermission()
}
}
private fun checkLocationPermission() {
when {
ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED -> {
fetchUserLocation()
}
shouldShowRequestPermissionRationale(
Manifest.permission.ACCESS_FINE_LOCATION
) -> {
showRationaleDialog(
"Location is needed to show nearby places."
) {
requestLocationPermission.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
}
else -> {
requestLocationPermission.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
}
}
private fun handleDenied() {
if (!shouldShowRequestPermissionRationale(
Manifest.permission.ACCESS_FINE_LOCATION)) {
showSettingsDialog()
}
}
}
Permission Changes You Need to Know By Android Version
| Android Version | Change | Impact |
|---|---|---|
| Android 6.0 (API 23) | Runtime permissions introduced | All dangerous permissions need runtime request |
| Android 10 (API 29) | Scoped Storage introduced | WRITE_EXTERNAL_STORAGE does nothing — use MediaStore API |
| Android 11 (API 30) | One-time permission option added | User can grant permission for this session only — handle re-request gracefully |
| Android 12 (API 31) | Approximate location option | User can grant ACCESS_COARSE_LOCATION even when you request ACCESS_FINE_LOCATION |
| Android 13 (API 33) | Granular media permissions | READ_EXTERNAL_STORAGE split into READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO |
Handling Android 13 media permissions correctly
// Request the right permission based on Android version
private fun requestMediaPermission() {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES // Android 13+
} else {
Manifest.permission.READ_EXTERNAL_STORAGE // Android 12 and below
}
if (ContextCompat.checkSelfPermission(this, permission)
== PackageManager.PERMISSION_GRANTED) {
openImagePicker()
} else {
requestSinglePermission.launch(permission)
}
}
Reusable Permission Utility
If you handle permissions across many screens, a simple utility function avoids repeating the same check-rationale-request flow everywhere:
// PermissionUtils.kt
object PermissionUtils {
fun isGranted(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) ==
PackageManager.PERMISSION_GRANTED
fun isPermanentlyDenied(activity: Activity, permission: String): Boolean =
!activity.shouldShowRequestPermissionRationale(permission) &&
!isGranted(activity, permission)
fun openAppSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
}
// Usage
if (PermissionUtils.isGranted(this, Manifest.permission.CAMERA)) {
openCamera()
} else if (PermissionUtils.isPermanentlyDenied(this, Manifest.permission.CAMERA)) {
PermissionUtils.openAppSettings(this)
} else {
requestCameraPermission.launch(Manifest.permission.CAMERA)
}
Best Practices
- Request permissions in context — ask for camera permission when the user taps the camera button, not on app launch. Upfront permission requests feel invasive and get denied more often. Request at the moment of need.
- Always check before requesting — call
checkSelfPermission()first. If it's already granted, proceed immediately without showing any dialog. - Handle one-time permissions gracefully — since Android 11, users can grant permissions for one session only. Always re-check permissions in
onResume()for features that depend on them. - Never request
WRITE_EXTERNAL_STORAGEfor new apps — it does nothing on Android 10+ due to Scoped Storage. UseMediaStoreAPI to write files instead. See our Android Shared Storage guide. - Don't repeatedly ask after permanent denial — if
shouldShowRequestPermissionRationale()returnsfalseafter a denial, the user has permanently denied it. Show the settings dialog — don't keep requesting. - Register the launcher at class level —
registerForActivityResult()must be called beforeonStart(). Declare it as a property, not inside a function or click listener.
Frequently Asked Questions
How do I request runtime permissions in Android with Kotlin?
Use ActivityResultContracts.RequestPermission() registered as a class property. Check with checkSelfPermission() first. Use shouldShowRequestPermissionRationale() to decide whether to show a rationale dialog before requesting. Handle the granted/denied result in the launcher callback.
What replaced onRequestPermissionsResult?
ActivityResultContracts.RequestPermission() and RequestMultiplePermissions() — registered via registerForActivityResult(). They must be declared at class level before onStart(). The old onRequestPermissionsResult() is deprecated since API 30.
How do I handle permanently denied permissions?
After denial, if shouldShowRequestPermissionRationale() returns false, the permission is permanently denied. Show a dialog and send the user to app settings via Settings.ACTION_APPLICATION_DETAILS_SETTINGS intent. Don't keep requesting — Android will silently ignore it.
What changed with permissions in Android 13?
READ_EXTERNAL_STORAGE was replaced with three granular permissions: READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, and READ_MEDIA_AUDIO. Always check Build.VERSION.SDK_INT and request the right permission for the device's Android version.
- Use
ActivityResultContracts.RequestPermission()—onRequestPermissionsResultis deprecated - Register launcher at class level — not inside
onCreate()or click listeners - Always check before requesting — skip the dialog if already granted
shouldShowRationale() = true→ show explanation dialog then requestshouldShowRationale() = falseafter denial → permanently denied → open Settings- Android 13+: use
READ_MEDIA_IMAGES/VIDEO/AUDIOnotREAD_EXTERNAL_STORAGE - Android 10+:
WRITE_EXTERNAL_STORAGEdoes nothing — use MediaStore API - Android 11+: re-check permissions in
onResume()— user may have granted one-time only