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

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:
| Concern | Dexter | Activity Result API |
|---|---|---|
| Maintenance | Archived July 2021, read-only | Maintained in AndroidX |
| Extra dependency | Yes | No — already in activity-ktx / fragment-ktx |
| Process death while the dialog is open | Known failure mode | Handled by the framework |
| Jetpack Compose | No support | First-class via rememberLauncherForActivityResult |
| New permission types (notifications, media) | Never added | Works 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.
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")
}
}
Migrating off Dexter
The mapping is close to one-to-one, which makes this a mechanical change rather than a redesign:
| Dexter | Replacement |
|---|---|
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 isSTARTEDor it throwsIllegalStateException. Register it as a field. - Forgetting the manifest entry. A runtime request for a permission not declared in
AndroidManifest.xmlis denied instantly with no dialog — which looks exactly like the user pressing "Deny". - Calling
shouldShowRequestPermissionRationale()before the first request. It returnsfalse, 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.




