Advertisement
Android Phones

Android Runtime Permissions with Dexter (and What to Use Instead in 2026)

Aditya SinghUpdated
Android Runtime Permissions with Dexter Library

Short answer: do not start a new project with Dexter. The library was archived by Karumi on 2 July 2021 and has been read-only ever since — no fixes for new Android releases, no new permission types, no support. The replacement ships with AndroidX: registerForActivityResult() with the RequestPermission and RequestMultiplePermissions contracts.

That replacement is roughly the same amount of code Dexter was written to save you, because the platform absorbed the hard parts. This guide gives you the modern implementation for single and multiple permissions, the rationale and permanent-denial handling that most tutorials leave out, and — because plenty of real codebases still contain it — a straight Dexter-to-AndroidX migration map.

In a hurry? Jump to: why not Dexter · single permission · multiple permissions · rationale + permanent denial · Jetpack Compose · migrating off Dexter · Dexter reference · 5 mistakes to avoid

Why not Dexter in 2026?

Dexter solved a real problem in 2015. Android 6.0 (API 23) moved permissions from install time to runtime, and the platform API of the day — onRequestPermissionsResult() with an integer request code — was verbose and easy to get wrong. Dexter wrapped it in a fluent builder.

Two things changed since:

ConcernDexterActivity Result API
MaintenanceArchived July 2021, read-onlyMaintained in AndroidX
Extra dependencyYesNo — already in activity-ktx / fragment-ktx
Process death while the dialog is openKnown failure modeHandled by the framework
Jetpack ComposeNo supportFirst-class via rememberLauncherForActivityResult
New permission types (notifications, media)Never addedWorks with any permission string

The decisive one is process death. Android can kill your app while the system permission dialog is showing — this is easy to reproduce with "Don't keep activities" in Developer Options. Dexter held its listener in memory, so the callback was gone when the process came back. The Activity Result API registers the callback before the activity is created, so it survives.

Requesting a single permission

Declare the permission in AndroidManifest.xml first. Nothing you do at runtime works without this:

<uses-permission android:name="android.permission.CAMERA" />

Then register the launcher as a field. It must be registered before the activity reaches STARTED, which in practice means at field initialisation or in onCreate() — never inside a click listener:

class MainActivity : AppCompatActivity() {

    private val requestCamera = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted: Boolean ->
        if (granted) {
            openCamera()
        } else {
            Toast.makeText(this, "Camera permission denied", Toast.LENGTH_SHORT).show()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<Button>(R.id.takePhoto).setOnClickListener {
            when {
                ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
                    == PackageManager.PERMISSION_GRANTED -> openCamera()

                shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) ->
                    showRationaleDialog()

                else -> requestCamera.launch(Manifest.permission.CAMERA)
            }
        }
    }
}

The when block is the whole permission flow: already granted, needs an explanation, or ask outright. Skipping the first branch and launching unconditionally is the most common bug in permission code — it works, but it re-runs the check on every tap for no reason.

Advertisement

Requesting multiple permissions

Use RequestMultiplePermissions. The callback hands you a Map<String, Boolean>, and the important detail is that partial grants are normal — the user can allow one and deny the other, so never treat the result as all-or-nothing:

private val requestPermissions = registerForActivityResult(
    ActivityResultContracts.RequestMultiplePermissions()
) { results: Map<String, Boolean> ->
    val camera = results[Manifest.permission.CAMERA] == true
    val audio = results[Manifest.permission.RECORD_AUDIO] == true

    when {
        camera && audio -> startVideoRecording()
        camera -> startPhotoOnlyMode()          // degrade, do not block
        else -> showFeatureUnavailable()
    }
}

// Launch with an array:
requestPermissions.launch(
    arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
)

Note that Android only shows a dialog for permissions that are not already granted, so you can safely pass the full array every time.

Rationale and permanent denial

This is the part Dexter's PermissionRequest callbacks were mostly used for, and it is worth getting right because Android gives you no direct "permanently denied" flag. You infer it:

private fun handleDenied(permission: String) {
    if (shouldShowRequestPermissionRationale(permission)) {
        // Denied once. The system will still show the dialog again,
        // so explain why you need it and re-launch.
        showRationaleDialog()
    } else {
        // Either permanently denied, or never asked.
        // Since we only reach here after a denial, treat it as permanent:
        // the system dialog will no longer appear. Send them to Settings.
        openAppSettings()
    }
}

private fun openAppSettings() {
    startActivity(
        Intent(
            Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
            Uri.fromParts("package", packageName, null)
        )
    )
}

The trap: shouldShowRequestPermissionRationale() also returns false before you have ever asked. Calling it to decide your first request sends brand-new users straight to a Settings screen for a permission they were never offered. Only call it after a denial.

On Android 11 and higher the system additionally treats two denials as a permanent one, so the user never sees a third dialog. Budget your requests: ask in context, at the moment the feature is used, not on first launch.

Permissions in Jetpack Compose

Compose has its own binding to the same contracts, which is one of the clearer reasons Dexter cannot come with you:

@Composable
fun CameraButton(onGranted: () -> Unit) {
    val context = LocalContext.current
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) onGranted()
    }

    Button(onClick = {
        val already = ContextCompat.checkSelfPermission(
            context, Manifest.permission.CAMERA
        ) == PackageManager.PERMISSION_GRANTED

        if (already) onGranted() else launcher.launch(Manifest.permission.CAMERA)
    }) {
        Text("Take photo")
    }
}
Advertisement

Migrating off Dexter

The mapping is close to one-to-one, which makes this a mechanical change rather than a redesign:

DexterReplacement
Dexter.withContext(this).withPermission(...)registerForActivityResult(RequestPermission())
.withPermissions(...)RequestMultiplePermissions()
onPermissionGranted(response)granted == true in the callback
onPermissionDenied(response)granted == false in the callback
response.isPermanentlyDenied!shouldShowRequestPermissionRationale(p) after a denial
onPermissionRationaleShouldBeShown(..., token)shouldShowRequestPermissionRationale(p) before launching
token.continuePermissionRequest()launcher.launch(p)
.check()— not needed

Then delete the dependency from your module build.gradle:

// Remove:
// implementation 'com.karumi:dexter:6.2.3'

// Keep (you almost certainly already have these):
implementation 'androidx.activity:activity-ktx:1.9.3'
implementation 'androidx.fragment:fragment-ktx:1.8.5'

Dexter reference (legacy code only)

If you have inherited a codebase that still uses Dexter and you are not migrating today, this is the shape it takes. It still compiles — the library was never removed from Maven Central — but treat it as frozen.

Dexter.withContext(this)
    .withPermissions(
        Manifest.permission.CAMERA,
        Manifest.permission.RECORD_AUDIO
    )
    .withListener(object : MultiplePermissionsListener {
        override fun onPermissionsChecked(report: MultiplePermissionsReport) {
            if (report.areAllPermissionsGranted()) {
                startRecording()
            }
            if (report.isAnyPermissionPermanentlyDenied) {
                openAppSettings()
            }
        }

        override fun onPermissionRationaleShouldBeShown(
            permissions: MutableList<PermissionRequest>,
            token: PermissionToken
        ) {
            token.continuePermissionRequest()
        }
    })
    .check()

One known issue worth flagging if you are debugging legacy Dexter code: because it runs its request through an internal transparent activity, it can lose its listener across configuration changes and process death, producing a permission dialog that appears to do nothing when dismissed. There is no fix — the repository is read-only.

5 mistakes to avoid

  • Registering the launcher inside a click listener. registerForActivityResult() must be called before the activity is STARTED or it throws IllegalStateException. Register it as a field.
  • Forgetting the manifest entry. A runtime request for a permission not declared in AndroidManifest.xml is denied instantly with no dialog — which looks exactly like the user pressing "Deny".
  • Calling shouldShowRequestPermissionRationale() before the first request. It returns false, and naive code reads that as permanent denial.
  • Asking for everything on first launch. Android 11+ turns two denials into a permanent one. Requests made before the user understands the feature burn that budget.
  • Treating a partial grant as total failure. Check each entry in the result map and degrade the feature instead of blocking it.

Which should you use?

For anything new, and for anything you are actively maintaining: the Activity Result API. It is built in, it survives process death, it works in Compose, and it accepts permission types that did not exist when Dexter was archived. Keep the Dexter section above bookmarked only for reading code you have not migrated yet.

Frequently Asked Questions

Is the Dexter permissions library still maintained?

No. Karumi archived the Dexter repository on 2 July 2021 and it has been read-only since. It still resolves from Maven Central and still compiles, but it receives no fixes, no support for new Android releases, and no support for permission types added after 2021.

What should I use instead of Dexter?

The AndroidX Activity Result API: registerForActivityResult() with ActivityResultContracts.RequestPermission() for one permission, or RequestMultiplePermissions() for several. It is already available through androidx.activity and androidx.fragment, so it adds no new dependency.

How do I request multiple permissions at once in Android?

Register a launcher with ActivityResultContracts.RequestMultiplePermissions() and call launch() with an array of permission strings. The callback receives a Map<String, Boolean>. Check each entry individually — users can grant one permission and deny another.

How do I detect a permanently denied permission?

Android exposes no direct flag. After a denial, call shouldShowRequestPermissionRationale(): if it returns false the system will no longer show the dialog, so treat it as permanent and send the user to app settings. Do not call it before the first request, because it also returns false then.

Do I still need to declare permissions in AndroidManifest.xml?

Yes. Runtime requests only work for permissions declared in the manifest. If the entry is missing, the request is denied immediately with no dialog shown, which is easy to mistake for the user tapping Deny.

Why did my permission callback never fire?

The usual cause is registering the launcher too late — registerForActivityResult() must be called before the activity reaches STARTED, so registering it inside a click listener throws IllegalStateException. With Dexter specifically, the callback can also be lost when the process is killed while the system dialog is open.

Does the Activity Result API work with Jetpack Compose?

Yes, through rememberLauncherForActivityResult() with the same RequestPermission and RequestMultiplePermissions contracts. Dexter has no Compose support, which is one of the clearer reasons to migrate.

Tutorial

Related Articles

Advertisement