Android Runtime Permissions with Kotlin — The Complete Guide

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.

Android runtime permissions with Kotlin showing single permission, multiple permissions, rationale dialog, and “Don’t Ask Again” handling

Android runtime permissions — the user grants or denies access at the time of use, not install

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
    }
}
💡 Why register at class level? 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_STORAGE for new apps — it does nothing on Android 10+ due to Scoped Storage. Use MediaStore API to write files instead. See our Android Shared Storage guide.
  • Don't repeatedly ask after permanent denial — if shouldShowRequestPermissionRationale() returns false after a denial, the user has permanently denied it. Show the settings dialog — don't keep requesting.
  • Register the launcher at class levelregisterForActivityResult() must be called before onStart(). 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.

📚 Continue Learning
📝 Summary
  • Use ActivityResultContracts.RequestPermission()onRequestPermissionsResult is 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 request
  • shouldShowRationale() = false after denial → permanently denied → open Settings
  • Android 13+: use READ_MEDIA_IMAGES/VIDEO/AUDIO not READ_EXTERNAL_STORAGE
  • Android 10+: WRITE_EXTERNAL_STORAGE does nothing — use MediaStore API
  • Android 11+: re-check permissions in onResume() — user may have granted one-time only

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

Post a Comment

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form