baaba_permission_handler 1.0.0 copy "baaba_permission_handler: ^1.0.0" to clipboard
baaba_permission_handler: ^1.0.0 copied to clipboard

Complete permission flow for Android and iOS: request, rationale, denied and permanently denied handling, Settings guidance dialogs. Fully customizable.

baaba_permission_handler #

pub package platforms

The complete runtime-permission flow for Flutter apps on Android and iOS, in one call:

final result = await AppPermission.camera.request();
if (result.isAllowed) openCamera();

permission_handler tells you a permission's status. Everything around it is left to your app: explaining why you need a permission, asking again after a denial, guiding the user to Settings after a permanent denial, noticing when they come back, and handling location services being switched off. Most apps rewrite that logic, with its edge cases, for every project. This package is that logic, built on permission_handler's native code and fully customizable.

Features #

  • One call covers every outcome. Rationale → system prompt → retry after a denial → a Settings dialog with numbered steps after a permanent denial → waits for the user to return and re-checks → offers to switch on location services or Bluetooth.
  • Zero setup. No init(), navigator key or BuildContext needed. Works with MaterialApp, GetMaterialApp and CupertinoApp.
  • Multiple permissions in one call: [AppPermission.camera, AppPermission.microphone].request(), grouped into one flow.
  • OS versions handled for you. Ask for AppPermission.photos and get the right native permission on every Android and iOS version, never storage on Android 13+, which can't be granted.
  • Fixes Android's hidden permanent denial. Android never reports "permanently denied" to a status check. This package does.
  • Fully customizable dialogs: wording, translations, per-permission reasons and steps, theme, RTL/LTR text direction, dialog / bottom sheet / snack bar, or your own widget.
  • Never throws. Every failure comes back as a result. Concurrent requests are queued, since Android rejects overlapping ones.
  • Extras: a PermissionGate widget, an observer for analytics, and a fake platform for widget tests.

Platform support #

Android iOS Web, desktop
✅ ✅ Reported as notApplicable

Android apps must compile against API 37 (compileSdk = 37), as required by permission_handler_android 14.x.

Getting started #

1. Install

flutter pub add baaba_permission_handler

2. Declare the permissions you use in AndroidManifest.xml and Info.plist. The next section, Platform setup, lists every entry.

3. Ask

import 'package:baaba_permission_handler/baaba_permission_handler.dart';

final result = await AppPermission.camera.request();
if (result.isAllowed) openCamera();

// Several at once:
final results = await [AppPermission.camera, AppPermission.microphone].request();
if (results.allAllowed) startVideoCall();

That's all. Configuration is optional; see Customization.

A single request() does all of this:

  1. Returns at once, with no UI, if the permission is already allowed or doesn't exist on this OS.
  2. Explains the request first if the user denied it before. When to explain is configurable.
  3. Shows the system prompt.
  4. If the user denies it, offers to ask again.
  5. If the user denies it permanently, shows a dialog with numbered steps for enabling it in Settings and an Open Settings button. It then waits for the user to come back and checks again.
  6. If location services or Bluetooth are switched off, offers to open that switch.

The import re-exports Permission, PermissionStatus and ServiceStatus from permission_handler, so you don't need that package as a direct dependency.

Table of contents #


Platform setup #

A permission works only when it is declared in the platform config. If a declaration is missing, the request fails silently. On Android the request is denied instantly with no prompt. On iOS it is denied, or the app crashes if the Info.plist key is missing. In debug builds the package prints a hint when it sees an instant denial.

Android #

1. Compile against API 37. permission_handler_android 14.x requires it. In android/app/build.gradle.kts:

android {
    compileSdk = 37
    // minSdk / targetSdk are unaffected
}

Without this, the build fails at checkDebugAarMetadata.

2. Declare what you use in android/app/src/main/AndroidManifest.xml, inside <manifest>:

AppPermission Manifest entries
camera CAMERA
microphone, speech RECORD_AUDIO
photos READ_MEDIA_IMAGES, READ_MEDIA_VISUAL_USER_SELECTED (partial access on 14+), READ_EXTERNAL_STORAGE with android:maxSdkVersion="32"
videos READ_MEDIA_VIDEO, READ_EXTERNAL_STORAGE with maxSdkVersion="32"
audio READ_MEDIA_AUDIO, READ_EXTERNAL_STORAGE with maxSdkVersion="32"
storage READ_EXTERNAL_STORAGE (and WRITE_EXTERNAL_STORAGE) with maxSdkVersion="32"
manageExternalStorage MANAGE_EXTERNAL_STORAGE
accessMediaLocation ACCESS_MEDIA_LOCATION
location ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION
locationAlways the two above, plus ACCESS_BACKGROUND_LOCATION
contacts READ_CONTACTS (and WRITE_CONTACTS if you write)
calendar READ_CALENDAR, WRITE_CALENDAR
calendarWriteOnly WRITE_CALENDAR
notification POST_NOTIFICATIONS
bluetooth BLUETOOTH_SCAN, BLUETOOTH_CONNECT, plus BLUETOOTH and BLUETOOTH_ADMIN with maxSdkVersion="30"
bluetoothAdvertise BLUETOOTH_ADVERTISE
nearbyWifiDevices NEARBY_WIFI_DEVICES
localNetwork ACCESS_LOCAL_NETWORK
phone READ_PHONE_STATE (plus CALL_PHONE etc. as needed)
sms READ_SMS, SEND_SMS, RECEIVE_SMS as needed
sensors BODY_SENSORS
sensorsAlways BODY_SENSORS, BODY_SENSORS_BACKGROUND
activityRecognition ACTIVITY_RECOGNITION
scheduleExactAlarm SCHEDULE_EXACT_ALARM
ignoreBatteryOptimizations REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
systemAlertWindow SYSTEM_ALERT_WINDOW
requestInstallPackages REQUEST_INSTALL_PACKAGES
accessNotificationPolicy ACCESS_NOTIFICATION_POLICY

Each entry is <uses-permission android:name="android.permission.NAME" />. The example manifest shows a complete, working set.

Google Play restricts MANAGE_EXTERNAL_STORAGE, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, SMS and background location, and requires a declaration form for them. Declare only what your app needs.

iOS #

1. Add a usage description to ios/Runner/Info.plist for every permission you use. iOS shows this text in the system prompt, and requesting a permission without its key crashes the app.

AppPermission Info.plist key
camera NSCameraUsageDescription
microphone NSMicrophoneUsageDescription
photos, videos NSPhotoLibraryUsageDescription
photosAddOnly NSPhotoLibraryAddUsageDescription
audio, mediaLibrary NSAppleMusicUsageDescription
location NSLocationWhenInUseUsageDescription
locationAlways NSLocationWhenInUseUsageDescription, NSLocationAlwaysAndWhenInUseUsageDescription (plus the Location updates background mode)
contacts NSContactsUsageDescription
calendar NSCalendarsFullAccessUsageDescription (iOS 17+), NSCalendarsUsageDescription (older)
calendarWriteOnly NSCalendarsWriteOnlyAccessUsageDescription
reminders NSRemindersFullAccessUsageDescription / NSRemindersUsageDescription
bluetooth NSBluetoothAlwaysUsageDescription
sensors, activityRecognition NSMotionUsageDescription
speech NSSpeechRecognitionUsageDescription
siri NSSiriUsageDescription (plus the Siri capability)
appTrackingTransparency NSUserTrackingUsageDescription
notification none
criticalAlerts none, but it needs Apple's entitlement

2. Enable the permission in the plugin. permission_handler compiles out any permission you don't enable. A permission that isn't enabled always reports denied, which is the most common "works on Android, broken on iOS" bug.

  • Swift Package Manager (the default on current Flutter): step 1 already enables it, because the plugin reads your Info.plist keys at build time. notification is on by default. criticalAlerts needs export PERMISSION_CRITICAL_ALERTS=1 before building. After changing keys, run rm -rf ~/Library/Developer/Xcode/DerivedData once.

  • CocoaPods: add macros to the post_install block in ios/Podfile:

    post_install do |installer|
      installer.pods_project.targets.each do |target|
        flutter_additional_ios_build_settings(target)
        target.build_configurations.each do |config|
          config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
            '$(inherited)',
            'PERMISSION_CAMERA=1',            # camera
            'PERMISSION_MICROPHONE=1',        # microphone
            'PERMISSION_PHOTOS=1',            # photos, videos, photosAddOnly
            'PERMISSION_LOCATION=1',          # location, locationAlways
            'PERMISSION_NOTIFICATIONS=1',     # notification
            'PERMISSION_CONTACTS=1',          # contacts
            'PERMISSION_EVENTS_FULL_ACCESS=1',# calendar (iOS 17+)
            'PERMISSION_EVENTS=1',            # calendar (older iOS)
            'PERMISSION_REMINDERS=1',         # reminders
            'PERMISSION_BLUETOOTH=1',         # bluetooth
            'PERMISSION_SENSORS=1',           # sensors, activityRecognition
            'PERMISSION_SPEECH_RECOGNIZER=1', # speech
            'PERMISSION_MEDIA_LIBRARY=1',     # audio, mediaLibrary
            'PERMISSION_APP_TRACKING_TRANSPARENCY=1',
            'PERMISSION_ASSISTANT=1',         # siri
            'PERMISSION_CRITICAL_ALERTS=1',
          ]
        end
      end
    end
    

    Keep only the lines you use.

App flavors that need different permission sets, builds started from the Xcode app, and troubleshooting are covered in the permission_handler README.

Quick start #

// Ask (shows dialogs as needed):
final result = await AppPermission.location.request();

// Check only, never asks and never shows UI:
final ok = await AppPermission.location.isAllowed;

// Several:
final results = await [AppPermission.camera, AppPermission.microphone].request();
results.allAllowed;          // everything usable?
results.permanentlyDenied;   // e.g. [AppPermission.microphone]
results[AppPermission.camera]!.isAllowed;

result.isAllowed is the one check you need. It is true for granted, limited ("selected photos") and not-applicable permissions. If you need details, result.state tells you exactly what happened (see Results and states).

Optional app-wide settings: call this once in main() if you want to change anything:

BaabaPermissions.init(PermissionConfig(
  appName: 'My App',
  textDirection: TextDirection.rtl,
  dialogBuilder: myBrandDialog,
));

The longer form BaabaPermissions.instance.request(AppPermission.camera) does exactly the same thing, and navigatorKey: / context: are still accepted when you want to pin dialogs to a particular navigator.

Permissions #

AppPermission names what the app wants to do. The package maps each value to the right native permission for the device it runs on.

AppPermission Android iOS
camera, microphone, contacts, notification, sensors ✓ ✓
photos 13+: READ_MEDIA_IMAGES · ≤12: storage Photos
videos 13+: READ_MEDIA_VIDEO · ≤12: storage Photos
audio 13+: READ_MEDIA_AUDIO · ≤12: storage Media library
photosAddOnly ≤9: storage · 10+: not applicable Photos (add only)
storage ≤12 only (13+: not applicable) not applicable
location while in use, checks location services while in use, checks location services
locationAlways while in use, then background while in use, then always
calendar / calendarWriteOnly read+write / write full access / write-only (iOS 17+)
bluetooth 12+: scan + connect · ≤11: service check only Core Bluetooth + service check
bluetoothAdvertise 12+ Core Bluetooth
activityRecognition 10+ Motion & fitness
speech microphone Speech recognition
sensorsAlways 13+: body sensors, then background Motion
nearbyWifiDevices 13+ –
localNetwork 17+ –
manageExternalStorage, accessMediaLocation, phone, sms, scheduleExactAlarm, ignoreBatteryOptimizations, systemAlertWindow, requestInstallPackages, accessNotificationPolicy ✓ –
mediaLibrary, reminders, criticalAlerts, siri, appTrackingTransparency – ✓

"–" and "not applicable" mean the state is PermissionState.notApplicable, and isAllowed treats that as true. There is nothing to ask for, so the feature can go ahead. For example, appTrackingTransparency on Android comes back allowed with no dialog.

To see exactly what will be requested on a given device:

final plan = await BaabaPermissions.instance.planFor(AppPermission.photos);
print(plan); // PermissionPlan([[Permission.photos]])

Results and states #

A call returns a PermissionResult. It never throws.

PermissionState Meaning isAllowed
granted Full access ✓
limited Partial access: selected photos (iOS 14+, Android 14+), selected contacts (iOS 18) ✓
provisional Quiet notifications (iOS) ✓
notApplicable Nothing to ask for on this OS ✓
denied Not granted; asking again will show the prompt
permanentlyDenied Not granted; only Settings can change it
restricted Blocked by parental controls or MDM (iOS)
serviceDisabled Granted, but location services or Bluetooth are off
error A platform call failed. See result.error

The result also records what happened during the call:

result.state            // final state
result.initialState     // state before the call
result.requested        // was the system prompt shown?
result.openedSettings   // was the user sent to Settings?
result.declinedByUser   // did they tap "Not now" on one of our dialogs?
result.statuses         // raw native statuses (e.g. background vs foreground location)
result.reason           // why it is notApplicable
result.error            // what failed, if state == error

declinedByUser lets you avoid asking again in the same session.

Several permissions at once #

final results = await [AppPermission.camera, AppPermission.microphone].request();

if (results.allAllowed) startVideoCall();
print(results.permanentlyDenied); // [AppPermission.microphone]

BaabaPermissions.instance.requestMany([...]) is the same call in its long form.

The permissions are handled as a group. The user sees one rationale listing each permission and its reason, and one system request per stage (Android shows it as a single flow). If several permissions end up needing Settings, they share one Settings dialog.

The flow in detail #

check ──► allowed / notApplicable ─────────────────────────────► done
  │
  ├─► restricted ──► restricted dialog (OK) ───────────────────► done
  │
  ├─► permanently denied (reported by iOS) ─────────────────┐
  │                                                          │
  └─► denied ──► rationale? ──Not now──► done (declined)     │
                    │ Continue                               │
                    ▼                                        │
                 system prompt(s), stage by stage            │
                    │                                        │
                    ├─► granted ──► service off? ──► service dialog ──► re-check
                    │
                    ├─► denied ──► denied dialog
                    │                ├─ Allow (≤ maxRetries) ──► prompt again
                    │                └─ Open Settings (retries used up) ──► re-check
                    │
                    └─► permanently denied ──► Settings dialog with steps ◄─┘
                                                 └─ Open Settings ──► wait for return ──► re-check

Stages. Some capabilities take more than one request. locationAlways asks for while-in-use first, because both OSes require that, and only then upgrades. sensorsAlways works the same way. A stage runs only after the stages before it are allowed.

Coming back from Settings. After it opens Settings, the flow waits until the app returns to the foreground and then checks again. So the returned result reflects what the user did there. If the settings screen never opened, the flow continues after 2 seconds. If the user never comes back, it gives up after settingsTimeout.

Serialized. Android rejects a second request while one is running. The package queues requests, so two widgets asking at once just take turns. A request made from inside a flow, such as from an observer or a custom presenter, runs immediately instead of waiting on itself.

Customization #

Set app-wide behavior in PermissionConfig. Override it for a single call with PermissionRequestOptions.

Rationale #

A rationale is the "here's why" dialog shown before the system prompt.

PermissionConfig(rationaleMode: RationaleMode.beforeFirstRequest)
RationaleMode Shown
never Never
beforeFirstRequest The first time only. Use this when the system prompt is your one chance, which it is on iOS
afterDenial (default) After the user has denied once: Android's shouldShowRequestRationale, or the package's own history
always Before every system prompt

Special-access Android permissions (systemAlertWindow, scheduleExactAlarm, ignoreBatteryOptimizations, manageExternalStorage, requestInstallPackages, accessNotificationPolicy) always get a rationale the first time, unless the mode is never. Requesting them leaves the app for a settings screen, so the jump needs explaining.

Which dialogs appear #

PermissionConfig(
  dialogs: DialogPolicy(
    denied: true,             // "Allow" to ask again
    permanentlyDenied: true,  // Settings steps + Open Settings
    restricted: true,
    serviceDisabled: true,
  ),
  maxRetries: 1,              // how many times "Allow" can re-prompt
)

DialogPolicy.none turns off every follow-up dialog. PermissionRequestOptions.silent turns off all dialogs, rationale included.

Wording and translation #

Per permission:

PermissionConfig(
  texts: {
    AppPermission.camera: PermissionTexts(
      rationale: 'We use the camera to scan your documents.',
      permanentlyDeniedMessage: 'Document scanning needs the camera.',
      androidSettingsSteps: [
        'Tap "{settings}" below.',
        'Tap Permissions → Camera.',
        'Choose "Allow only while using the app".',
      ],
      icon: Icons.badge_outlined,
    ),
  },
)

Generic strings: every title, message, button label and default step list lives in PermissionStrings:

PermissionConfig(
  strings: PermissionStrings(
    openSettingsLabel: 'Go to Settings',
    notNowLabel: 'Maybe later',
  ),
)

Strings may use these placeholders: {app}, {permission} (the names, joined as "Camera and Microphone"), {service} and {settings} (the Open Settings button label).

For a localized app, build the strings from the context:

PermissionConfig(
  stringsBuilder: (context) => switch (Localizations.localeOf(context).languageCode) {
    'ur' => const PermissionStrings(
        continueLabel: 'جاری رکھیں',
        notNowLabel: 'ابھی نہیں',
        openSettingsLabel: 'سیٹنگز کھولیں',
        // ...
      ),
    _ => const PermissionStrings(),
  },
  // Return per-permission wording from your own translations, or null for the defaults:
  textsBuilder: (context, permission) => null,
)

Wording resolves in this order: built-in defaults → texts → textsBuilder → the per-call options.texts.

Look and feel #

The built-in dialogs follow your app's Theme. Adjust them with:

PermissionConfig(
  theme: PermissionDialogTheme(
    accentColor: Colors.teal,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
    titleStyle: TextStyle(fontWeight: FontWeight.w700),
    showIcon: true,
    showSteps: true,
    adaptive: true, // Cupertino alert on iOS
  ),
)

Text direction (RTL / LTR) #

By default, dialogs inherit the text direction of your app. To choose it yourself, for example Urdu dialogs in an app that is otherwise left-to-right:

PermissionConfig(
  textDirection: TextDirection.rtl,          // every dialog, sheet, snack bar and PermissionGate
  theme: PermissionDialogTheme(
    textAlign: TextAlign.start,              // title + message alignment; default is centered
  ),
)

Set it for a single request:

AppPermission.location.request(
  options: const PermissionRequestOptions(textDirection: TextDirection.rtl),
);

Or change it when the user switches language:

void onLanguageChanged(bool urdu) {
  final p = BaabaPermissions.instance;
  p.config = p.config.copyWith(
    textDirection: urdu ? TextDirection.rtl : TextDirection.ltr,
  );
}

The direction applies to the built-in dialogs, the Cupertino dialog, bottom sheets, snack bars and the PermissionGate fallback. It also applies to your own dialogBuilder widget. The builder is called with a context that is already inside the configured direction, so Directionality.of(context) and start/end alignment work as expected. The resolved value is also available as data.textDirection and data.isRtl, for layouts that need to branch, such as reordering buttons.

Steps and list rows always align to the start of the direction, so numbered steps sit on the right in RTL and on the left in LTR.

Dialog, sheet or snack bar #

PermissionConfig(
  presentation: PermissionPresentation.bottomSheet,
  presentationByKind: {
    // A soft, non-blocking nudge after a plain denial.
    PermissionDialogKind.denied: PermissionPresentation.snackBar,
  },
)

The package falls back instead of crashing. A snack bar with no ScaffoldMessenger above it becomes a dialog. A sheet in an app without Material localizations, such as a CupertinoApp, also becomes a dialog.

Your own dialog #

There are two levels.

dialogBuilder replaces the content. The package still presents it using the configured presentation. Every string arrives already resolved:

PermissionConfig(
  dialogBuilder: (context, data, respond) => MyBrandDialog(
    title: data.title,
    body: data.message,
    steps: data.steps,           // numbered Settings steps, if any
    items: data.items,           // one per permission when several
    confirm: data.primaryLabel,
    cancel: data.secondaryLabel, // null for one-button dialogs
    onConfirm: () => respond(PermissionDialogAction.primary),
    onCancel: () => respond(PermissionDialogAction.secondary),
  ),
)

PermissionPromptContent is the built-in body, exported so you can wrap it in your own chrome.

presenter takes over the whole presentation, such as a full-screen page or your design system's modal:

PermissionConfig(
  presenter: (context, data) => Navigator.of(context).push<PermissionDialogAction>(
    MaterialPageRoute(builder: (_) => PermissionExplainerPage(data: data)),
  ),
)

data.kind tells you which moment you're in: rationale, denied, permanentlyDenied, restricted or serviceDisabled.

Per-call overrides #

Different screens need different reasons for the same permission:

await AppPermission.location.request(
  options: PermissionRequestOptions(
    rationaleMode: RationaleMode.always,
    texts: {
      AppPermission.location: PermissionTexts(
        rationale: 'Share your live location so the driver can find you.',
      ),
    },
    presentation: PermissionPresentation.bottomSheet,
  ),
);

PermissionGate #

PermissionGate shows its child only once the permission is allowed, and keeps checking. It re-checks when the app returns to the foreground and after any request made through the same instance.

PermissionGate(
  permission: AppPermission.camera,
  child: const QrScanner(),
)

PermissionGate.many(
  permissions: [AppPermission.camera, AppPermission.microphone],
  requestOnMount: true,
  fallbackBuilder: (context, results, request, openSettings) => MyEmptyState(
    onAllow: results.permanentlyDenied.isEmpty ? request : openSettings,
  ),
  child: const VideoCall(),
)

The built-in fallback (PermissionRequiredView) shows an icon, a reason and one button. The button requests the permission, or opens Settings when only Settings can help.

Watching for changes #

BaabaPermissions.instance.watch(AppPermission.notification).listen((r) {
  // Fires now, then whenever the state changes: after the app resumes
  // (the user may have changed it in Settings) or after a request.
});

BaabaPermissions.instance.results.listen((r) { /* every request result */ });

For your own "Enable in Settings" button:

final results = await BaabaPermissions.instance.openSettingsAndRecheck([AppPermission.camera]);

Android's hidden permanent denial #

Android never reports permanentlyDenied to a status check. Only a request returns it. So a plain check() would report denied for a permission the OS will never prompt for again.

The package fills this gap by remembering what the last request returned, in a PermissionStore. The default store lives in memory, which covers the current session. A request always gets the truth anyway. Persist the store if check() must be accurate right after a cold start:

class PrefsPermissionStore extends PermissionStore {
  PrefsPermissionStore(this.prefs);
  final SharedPreferences prefs;

  String _key(AppPermission p) => 'baaba_permission.${p.name}';

  @override
  PermissionHistory? read(AppPermission p) {
    final raw = prefs.getString(_key(p));
    return raw == null ? null : PermissionHistory.fromJson(jsonDecode(raw));
  }

  @override
  Future<void> write(AppPermission p, PermissionHistory h) =>
      prefs.setString(_key(p), jsonEncode(h.toJson()));

  @override
  Future<void> clear() async {
    for (final p in AppPermission.values) {
      await prefs.remove(_key(p));
    }
  }
}

BaabaPermissions.init(PermissionConfig(store: PrefsPermissionStore(prefs)));

A remembered permanent denial is still requested when you call request(). If the user has since reset the permission to "Ask every time" in Settings, the prompt appears. If not, Android answers at once without a prompt, and the Settings dialog follows.

Observability #

class AnalyticsObserver extends PermissionObserver {
  @override
  void onResult(PermissionResult r) =>
      analytics.log('permission', {'name': r.permission.name, 'state': r.state.name});

  @override
  void onDialogResult(PermissionDialogKind kind, List<AppPermission> p, PermissionDialogAction a) =>
      analytics.log('permission_dialog', {'kind': kind.name, 'action': a.name});

  @override
  void onError(Object error, StackTrace stack, String where) =>
      crashReporter.record(error, stack, reason: 'permission $where');
}

Every callback is wrapped in try/catch, so a broken observer can't break a request.

Testing #

FakePermissionPlatform is an in-memory OS. It runs the real flow, dialogs included, in a widget test:

import 'package:baaba_permission_handler/testing.dart';

testWidgets('asks for Settings after a permanent denial', (tester) async {
  final os = FakePermissionPlatform() // Android 14 by default; PlatformInfo.ios() for iOS
    ..whenRequested(Permission.camera, PermissionStatus.permanentlyDenied)
    ..onOpenAppSettings = (os) => os.setStatus(Permission.camera, PermissionStatus.granted);
  final permissions = os.createHandler();
  await tester.pumpWidget(const MaterialApp(home: SizedBox()));

  final future = permissions.request(AppPermission.camera);
  await tester.pumpAndSettle();
  await tester.tap(find.text('Open Settings'));
  await tester.pumpAndSettle();

  expect((await future).isGranted, isTrue);
  expect(os.appSettingsOpened, 1);
});

The fake behaves like a real OS in the ways that matter. On Android, a status check reports a permanent denial as denied. A permanently denied or restricted permission never prompts. An unscripted request is answered granted. You can also script rationale, service status, errors (statusError, requestError), and inspect requests.

Pass your handler into the code under test (for example BaabaPermissions(platform: os, ...)), or install it as the shared instance with BaabaPermissions.init(config, platform: os, settingsWaiter: const ImmediateSettingsReturnWaiter()). Call BaabaPermissions.reset() in tearDown.

Gotchas #

  • iOS terminates your app when the user changes a privacy permission in Settings. Coming back is then a cold start, and the request() future from before simply never completes. Check permissions on startup as well as after a request.
  • The iOS system prompt appears once. After a denial, iOS reports permanentlyDenied and only Settings can change it. That's why RationaleMode.beforeFirstRequest is worth considering for important permissions on iOS.
  • On Android 11+, denying twice is permanent. Dismissing the prompt by tapping outside it doesn't count as a denial. The default maxRetries: 1 gives exactly one second chance.
  • Background location: on Android 11+ the "Allow all the time" step opens a settings page rather than a dialog. Apple and Google both expect you to have foreground location first, which is what the flow does.
  • Storage on Android 13+ can't be granted. Use photos, videos or audio, which pick the right permission on every version. storage on 13+ comes back notApplicable instead of permanently denied.
  • Instant denial. If a request comes back denied with no prompt, the permission is missing from AndroidManifest.xml, Info.plist or the Podfile. Debug builds print a hint.
  • Photo picker. If you only need the user to pick an image, the system photo picker (for example, image_picker on Android 13+ and iOS 14+) needs no permission at all.

Configuration reference #

PermissionConfig Default
navigatorKey null Not needed: the root navigator is found automatically. Set it only to pin dialogs to a specific navigator
appName null Fills {app}; falls back to strings.fallbackAppName
rationaleMode afterDenial See Rationale
dialogs all on DialogPolicy: which follow-up dialogs appear
presentation dialog dialog / bottomSheet / snackBar
presentationByKind {} Presentation per dialog kind
strings / stringsBuilder English Generic wording; the builder is for i18n
texts / textsBuilder built-in Per-permission wording and icon
theme from Theme PermissionDialogTheme
dialogBuilder null Replace the dialog content
presenter null Replace the whole presentation
barrierDismissible true Tap outside to dismiss
textDirection inherited TextDirection.rtl / ltr for all package UI; per call via options
maxRetries 1 Re-prompts offered by the denied dialog
checkServices true Check location services / Bluetooth
recheckAfterSettings true Wait for the return from Settings, then re-check
settingsTimeout 5 min Stop waiting for the return
store in memory PermissionStore for request history
resolver built-in Override the capability → native mapping; return null to fall back
observer null PermissionObserver
debugLogging true Debug-only hints about misconfiguration

PermissionRequestOptions takes context, showDialogs, rationaleMode, dialogs, presentation, presentationByKind, texts, theme, dialogBuilder, presenter, textDirection, maxRetries, checkServices and recheckAfterSettings. Any value left null falls back to the config.

Additional information #

  • Example app: example/ is a permission explorer with a complete, working Android and iOS configuration. It covers every presentation, the rationale modes, a custom-branded dialog, RTL, and PermissionGate.
  • Native layer: permission requests go through permission_handler. Platform behavior and its setup options, such as app flavors and Xcode builds, are documented there.
  • Issues and contributions: please use the issue tracker of the repository linked on this page, and include the device, OS version and a PermissionResult printout. result.toString() includes the state and what the flow did.
  • Changelog: see CHANGELOG.md.
  • License: MIT, see LICENSE.
0
likes
150
points
422
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Complete permission flow for Android and iOS: request, rationale, denied and permanently denied handling, Settings guidance dialogs. Fully customizable.

Topics

#permissions #permission-handler #android #ios #dialog

License

MIT (license)

Dependencies

app_settings, device_info_plus, flutter, permission_handler

More

Packages that depend on baaba_permission_handler