
Mobile App Runtime Permission Debugging Guide (2026)
Every mobile developer has experienced it: your app works perfectly in development, passes QA, and goes to production — then suddenly crashes on a subset of devices with a cryptic stack trace. Dig deeper and you'll often find the culprit: a runtime permission denial that your code never expected. Mobile app runtime permission debugging is one of the most overlooked skills in mobile development, responsible for an estimated 15% of production crashes that crash reporting tools fail to properly contextualize.
Runtime permissions transformed mobile security when Android 6.0 (Marshmallow) introduced them in 2015 and iOS tightened its permission model. But they also created a new class of bugs: crashes that manifest only when users deny permissions, select "Don't Ask Again," or when background restrictions revoke access. This guide covers everything you need to debug permission-related failures on both platforms.
Why Runtime Permissions Crash Your App
Before runtime permissions, apps declared everything in their manifest and users accepted or rejected the bundle at install. That model was simple but terrible for privacy. The shift to runtime permissions created a problem: your code can no longer assume any permission is granted.
The most common failure modes include:
- Silent denial: The user taps "Deny" and your app receives a callback. If your code doesn't handle this gracefully, the next API call that requires that permission throws a SecurityException.
- "Don't Ask Again": On Android, if a user denies a permission twice, the system permanently suppresses the prompt. Your
shouldShowRequestPermissionRationale()call returns false, and your re-prompting logic never fires. - Background restrictions: Starting with Android 11, background location requires a separate permission flow. iOS 19's privacy dashboard makes it easier for users to revoke permissions they forgot they granted.
- OS-level revocations: Both platforms now auto-reset permissions for unused apps. Your app launches to find its camera or location permission gone.
Traditional crash reporters show you the stack trace — java.lang.SecurityException: Permission denied — but they don't tell you why the permission was denied, what the user did to trigger it, or whether it's part of a broader pattern. That's where dedicated permission debugging comes in.
Android Runtime Permission Debugging
Android's permission model has evolved significantly from the simple checkSelfPermission() call to a complex system involving scoped storage, foreground service permissions, and the new privacy dashboard in Android 15 and 16. The Android developer documentation on permissions outlines the flow, but production debugging requires understanding what happens when users deviate from the happy path.
The Permission Request Flow
The basic flow looks straightforward: check the permission, request it if needed, handle the result. But production bugs hide in the edge cases:
// Check current permission state
when {
ContextCompat.checkSelfPermission(
context, Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED -> {
// Permission is granted — open camera
openCamera()
}
ActivityCompat.shouldShowRequestPermissionRationale(
activity, Manifest.permission.CAMERA
) -> {
// User denied once but hasn't checked "Don't Ask Again"
// Show educational UI explaining WHY you need the permission
showPermissionRationale()
}
else -> {
// Either first request OR user checked "Don't Ask Again"
// Direct request with graceful fallback
requestPermission()
}
}The critical debugging insight: when shouldShowRequestPermissionRationale() returns false, you cannot distinguish between a first-time request and a permanent denial. Many apps handle this incorrectly, showing rationales to first-time users (annoying) or failing to redirect permanently-denied users to settings.
Debugging "Never Ask Again"
When a user permanently denies a permission, your only path is to direct them to the app's settings screen. But many apps don't detect this state properly. Here's a robust debugging pattern:
fun handlePermissionDenied(permission: String) {
if (!ActivityCompat.shouldShowRequestPermissionRationale(
activity, permission)) {
// Permanent denial — user must enable in Settings
// CRITICAL: Log this as a breadcrumb for crash context
BugsPulse.logBreadcrumb("permission_permanently_denied", mapOf(
"permission" to permission,
"screen" to this::class.simpleName
))
showSettingsRedirect()
}
}By logging a breadcrumb when a permission is permanently denied, you create a forensic trail. If a crash follows 30 seconds later when your code tries to use that permission, you can correlate the denial with the crash — something raw stack traces can't do alone. The Google Play permissions policy requires apps to handle denial gracefully, making this both a technical and compliance necessity.
Scoped Storage and File Permissions
Android 10 introduced scoped storage, and Android 11 made it mandatory for apps targeting API 30+. Permissions like READ_EXTERNAL_STORAGE no longer grant broad file access. Apps must use the MediaStore API or Storage Access Framework as documented in the Android scoped storage guide.
The most common debugging scenario: your app requests READ_EXTERNAL_STORAGE, receives a grant, but then crashes when trying to access a file path directly. The fix is migrating to MediaStore queries:
// DON'T: Direct file access (crashes on API 30+)
val file = File("/storage/emulated/0/DCIM/photo.jpg")
// DO: Query MediaStore
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection, null, null, null
)Background Location Permission Changes
Android 15 and 16 introduced additional background permission restrictions. Apps must now go through a multi-step flow: foreground location first, then background location with explicit user consent. The debugging challenge is that the background permission request silently fails if foreground wasn't granted first — with no exception thrown. Refer to the Android background location access documentation for the complete flow requirements.
For more on ANRs that can result from permission-blocked operations, see our Android ANR Detection and Prevention guide.
iOS Permission Prompt Debugging
iOS permission debugging presents different challenges. Where Android throws SecurityException, iOS often silently returns empty data or a generic error code. This silent failure pattern makes permission bugs harder to detect — users report "the camera doesn't work" rather than "permission denied." The Apple authorization documentation covers the API surface, but real-world debugging requires understanding the full state machine.
Info.plist Permission Keys
Every iOS permission requires a corresponding Info.plist key with a purpose string. Missing a key means the prompt never appears and your authorization request silently fails:
<key>NSCameraUsageDescription</key>
<string>We need camera access to scan QR codes for device pairing</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location helps us find nearby devices to connect to</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Access to photos lets you customize your profile picture</string>The debugging pitfall: if your purpose string is empty, poorly written, or missing, the system kills your app when you request authorization — and the crash log won't mention permissions. Always verify your Info.plist keys before shipping.
Handling Authorization States
iOS authorization has more states than granted/denied. The complete state machine includes: NotDetermined, Restricted (parental controls), Denied, Authorized, Provisional (for notifications), and Ephemeral. Each requires different handling:
import AVFoundation
func checkCameraPermission() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
startCamera()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
if granted { self.startCamera() }
else { self.showCameraDeniedUI() }
}
}
case .denied:
// User explicitly denied — show settings redirect
showSettingsAlert(for: "Camera")
case .restricted:
// Device policy restricts access (MDM, parental controls)
showRestrictedAccessMessage()
@unknown default:
// Future-proof: handle new authorization states
logUnknownPermissionState()
}
}Photo Library Limited Access
iOS 14 introduced limited photo library access, where users can grant access to selected photos only. Every time your app relaunches, it must check whether the selection has changed. Apps that assume full access after a one-time authorization will crash or show outdated data. Use PHPhotoLibrary.authorizationStatus(for: .readWrite) and handle .limited explicitly.
Location Permission Complexity
iOS location permissions are particularly debugging-intensive. The system can deliver "Allow Once," "Allow While Using App," or "Allow Always" — and users can change their mind at any time. Your app must handle transitions between all states, including background-to-foreground permission changes triggered by the iOS privacy dashboard.
Building Resilient Permission Denial Flows
The best permission debugging strategy is preventing crashes before they happen. Resilient permission denial flows ensure your app degrades gracefully regardless of what the user chooses.
Graceful Degradation Patterns
Every feature that requires a permission needs a fallback design:
- Camera denied: Show a placeholder image plus a "Enable Camera" button that opens Settings
- Location denied: Allow manual address entry as an alternative to GPS
- Contacts denied: Provide a manual phone number input instead of contact picker
- Notifications denied: Show an in-app notification center as a fallback
Re-Prompting Strategies
Users who deny permissions once might reconsider — but only if asked at the right moment. Effective re-prompting:
- Never interrupt a task — wait until the user tries to use the feature
- Explain the value — "Camera access lets you scan barcodes instantly"
- Limit frequency — don't ask more than once per session
- Respect permanent denials — after two denials, only offer a Settings link
Testing Denial Flows
Systematically test every permission denial path. A comprehensive test matrix:
| Permission | Grant | Deny | Don't Ask Again | Revoke in Settings | Revoke While Backgrounded |
|---|---|---|---|---|---|
| Camera | ✓ | ✓ | ✓ | ✓ | ✓ |
| Location | ✓ | ✓ | ✓ | ✓ | ✓ |
| Microphone | ✓ | ✓ | ✓ | ✓ | ✓ |
| Photos | ✓ | ✓ | ✓ | ✓ | ✓ |
| Contacts | ✓ | ✓ | ✓ | ✓ | ✓ |
| Notifications | ✓ | ✓ | ✓ | ✓ | ✓ |
Debugging Permission Crashes with Monitoring Tools
Crash reporting tools like BugsPulse go beyond stack traces. When your app crashes from a permission issue, you need to know what permission was denied, what screen the user was on, and what led up to the denial.
Permission-Aware Breadcrumbs
Instrument your permission flows with breadcrumbs that capture the full state at decision points:
// Log permission state changes as breadcrumbs
fun onPermissionResult(permission: String, granted: Boolean, permanentDenial: Boolean) {
BugsPulse.logBreadcrumb("permission_result", mapOf(
"permission" to permission,
"granted" to granted.toString(),
"permanent_denial" to permanentDenial.toString(),
"timestamp" to System.currentTimeMillis().toString(),
"current_screen" to getCurrentScreenName()
))
}These breadcrumbs create a timeline that makes permission-related crashes immediately diagnosable. Instead of guessing, you can trace the exact permission flow that preceded the crash.
Correlating Permission Changes with Crash Spikes
After a new release, watch for crash spikes correlated with permission events. If your crash rate jumps 3% on devices where users denied camera access, you've found a missing fallback path. Permission-aware monitoring surfaces these patterns automatically.
Testing Permission Scenarios
Automated testing catches permission bugs before users do. Both platforms provide APIs for testing flows:
Android (UI Automator / Espresso):
// Grant permission before test
@get:Rule
val grantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION
)
// Test denial flow
@Test
fun testCameraDenied() {
// Revoke permission mid-test using adb
// adb shell pm revoke com.yourapp android.permission.CAMERA
onView(withId(R.id.camera_button)).perform(click())
onView(withText("Camera permission is required")).check(matches(isDisplayed()))
}iOS (XCUITest):
// Reset all permission state before test
let app = XCUIApplication()
app.resetAuthorizationStatus(for: .camera)
app.launch()
// Test denial flow
app.buttons["cameraButton"].tap()
XCTAssertTrue(app.staticTexts["Camera permission needed"].exists)A comprehensive permission testing checklist:
- All permissions requested at appropriate times (not all at launch)
- Grant → deny → grant again cycle doesn't produce inconsistent state
- "Don't Ask Again" path shows Settings redirect
- Background app → revoke permission in Settings → foreground app handled
- Limited access modes (iOS Photo Library) properly handled
Conclusion
Runtime permission debugging isn't just about fixing crashes — it's about trust. When your app handles permission denials gracefully, users feel in control. When it crashes because they said "no," they uninstall and leave a one-star review.
The key patterns to implement: always check permission state before using a protected API, log permission transitions as breadcrumbs for crash context, build graceful fallbacks for every denial path, and test every permission state. With these practices and a permission-aware monitoring tool, you can eliminate the 15% of crashes that stem from runtime permission failures.
Ready to debug permission issues before your users find them? Start monitoring with BugsPulse free and get permission-aware crash reporting that shows you exactly what went wrong.