Runtime Permissions
4 min read
Platform Integration
First time:
status → denied → request() → user picks Allow/Deny
→ granted ✅
→ denied → can ask again next time
After "Don't ask again" (Android) / second Deny (iOS):
status → permanentlyDenied → request() returns same status without prompting
✅ Send to app settings; user enables there.
| Status | What it means | What to do |
|---|---|---|
granted | All good | Proceed |
denied (first time) | Not asked yet | Show rationale (if helpful), then request() |
denied (after request) | User declined | Optional: show contextual UI, retry later |
permanentlyDenied | OS won't show prompt again | Show "open Settings" UI |
restricted (iOS) | Parental controls / MDM | Show explanation; no recovery in-app |
limited (iOS, photos) | User granted partial access | Use what you have; offer "select more" UI |
Code in action
class PermissionService {
Future<bool> request(
Permission p, {
required String rationale,
required BuildContext context,
}) async {
var status = await p.status;
if (status.isGranted) return true;
if (status.isRestricted) {
await _showInfo(context, 'Restricted by device policy.');
return false;
}
// Permanently denied — only Settings can fix it
if (status.isPermanentlyDenied) {
final go = await _confirm(context,
'Permission needed',
'$rationale\n\nPlease enable it in Settings.',
confirmLabel: 'Open Settings');
if (go) await openAppSettings();
return (await p.status).isGranted;
}
// First-time or recoverable denial — show rationale, then prompt
final ok = await _confirm(context, 'Permission needed', rationale,
confirmLabel: 'Continue');
if (!ok) return false;
status = await p.request();
return status.isGranted;
}
Future<bool> _confirm(BuildContext c, String title, String msg,
{required String confirmLabel}) async => await showDialog<bool>(
context: c,
builder: (_) => AlertDialog(
title: Text(title),
content: Text(msg),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(c, true), child: Text(confirmLabel)),
],
),
) ?? false;
}
// Usage — at the moment of need
final ok = await permissions.request(
Permission.camera,
rationale: 'We need camera access to scan QR codes.',
context: context,
);
if (ok) openCamera();
else showFallbackUI();
Required platform declarations
iOS — Info.plist:
<key>NSCameraUsageDescription</key>
<string>We use the camera to scan QR codes.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to find nearby stores.</string>
Android — AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Without these, requesting will silently fail or the OS will reject your binary at review.
Permission UX best practices
| Practice | Why |
|---|---|
| Request just-in-time (when the user taps "Scan QR") | Highest grant rates; clearest context |
| Show rationale before the OS prompt for non-obvious permissions | iOS rejects apps without explanation strings; Android best practice |
| Always have a fallback if denied (manual entry instead of QR scan) | Don't dead-end the user |
| Re-check status when the user returns from Settings | They may have changed it |
| Don't request multiple permissions at once unless necessary | Single permission prompts have higher accept rates |
| Pre-prompt on a screen you control (not a system dialog) | You can explain WHY first, with your branding |
Common mistakes to avoid
❌ Requesting permissions on app startup
Users haven't seen the value yet; high rejection rate, and iOS now warns about this.
✅ Request when the user triggers the feature that needs it.
❌ Not declaring permissions in Info.plist / AndroidManifest.xml
iOS request returns instant denial; Android crashes on access.
❌ Showing the system prompt with no context
"Allow MyApp to access your camera?" — user has no idea why.
✅ Pre-prompt with rationale; THEN call request().
❌ Ignoring permanentlyDenied
The system won't prompt — your app appears to do nothing when the user taps "Scan".
✅ Detect it and offer "Open Settings".
❌ Not re-checking after openAppSettings()
User enables it; your app still thinks it's denied because you cached the old status.
✅ Re-read status when the app resumes (use WidgetsBindingObserver).
❌ Requesting permissions you don't actually use
App Store review rejects "unused permissions"; users get suspicious.
✅ Request only what's needed for the feature actually shipped.
Interview follow-ups
-
What's the difference between
deniedandpermanentlyDenied?deniedmeans the user said no, but you can still prompt again.permanentlyDenied(Android) / second-time iOS denial means the OS will not show the system prompt again — callingrequest()returns the same status without prompting. Recovery requiresopenAppSettings(). -
Why request just-in-time vs at startup? Three reasons: (1) higher grant rates — users grant when they understand why; (2) iOS App Store guidelines push for it; (3) less friction in onboarding. A 4-permission startup wall kills retention.
-
How do you handle the iOS "limited" photos permission? iOS 14+ lets users grant access to specific photos rather than all.
Permission.photos.statusreturnslimited. Treat it like granted — but offer a "Select more photos" entry point (viaPHPickerViewControllerexposed byimage_pickerorphoto_manager). -
What about permissions that require multiple steps (notifications, background location)? Notifications often have a multi-stage flow (provisional → explicit). Background location requires foreground location first, then "Always" — you must request them sequentially with rationale between. Build a state machine; don't try to fire-and-forget.
How helpful was this content?
Please sign in to rate this article.