Runtime Permissions

Medium PriorityAsked in ~55% of senior interviews

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.
StatusWhat it meansWhat to do
grantedAll goodProceed
denied (first time)Not asked yetShow rationale (if helpful), then request()
denied (after request)User declinedOptional: show contextual UI, retry later
permanentlyDeniedOS won't show prompt againShow "open Settings" UI
restricted (iOS)Parental controls / MDMShow explanation; no recovery in-app
limited (iOS, photos)User granted partial accessUse 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

PracticeWhy
Request just-in-time (when the user taps "Scan QR")Highest grant rates; clearest context
Show rationale before the OS prompt for non-obvious permissionsiOS 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 SettingsThey may have changed it
Don't request multiple permissions at once unless necessarySingle 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

  1. What's the difference between denied and permanentlyDenied? denied means 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 — calling request() returns the same status without prompting. Recovery requires openAppSettings().

  2. 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.

  3. How do you handle the iOS "limited" photos permission? iOS 14+ lets users grant access to specific photos rather than all. Permission.photos.status returns limited. Treat it like granted — but offer a "Select more photos" entry point (via PHPickerViewController exposed by image_picker or photo_manager).

  4. 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.