baaba_permission_handler 1.1.0 copy "baaba_permission_handler: ^1.1.0" to clipboard
baaba_permission_handler: ^1.1.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, sensors ✓ ✓
notification 13+: POST_NOTIFICATIONS · ≤12: Settings only (see below) ✓
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 – ✓

Below Android 13, notifications have no runtime permission and no prompt. The status follows the app's notification switch. When it is off, notification is reported as permanentlyDenied and the flow goes straight to the Settings dialog.

"–" 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. On Android each background permission is requested in a call of its own, because Android 11+ ignores background location bundled with anything else.

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). Button labels are filled too, so allowLabel: 'Allow {permission}' works.

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. Each layer overrides only the fields it sets, so a per-call rationale keeps the name from your translations.

Translating the rationales. Every built-in permission comes with its own English rationale, so PermissionStrings.rationaleMessage is only the fallback for a permission without one. To translate the rationales, set them per permission in texts or textsBuilder, along with the names.

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,
  },
)

A snack bar that nobody taps disappears after PermissionDialogTheme.snackBarDuration (6 seconds by default) and counts as dismissed. It takes the theme's backgroundColor, padding and accentColor (for its button), and its button follows textDirection like the text.

The package falls back instead of crashing or waiting forever. A snack bar becomes a dialog when it couldn't be seen right away:

  • there is no ScaffoldMessenger above it, or no Scaffold on screen to show it in;
  • the app's own snack bar is already up, so ours would wait behind it;
  • a screen reader is on, so a timed prompt would be easy to miss.

A sheet in an app without Material localizations, such as a CupertinoApp, also becomes a dialog. A context with no Navigator above it is skipped in favor of the app's root navigator. Dialogs and bottom sheets always open on the root navigator, never inside a nested (tab) one. If a dialog throws, for example from your presenter, the error goes to the observer and the flow continues as if no dialog could be shown, so a rationale still leads to the system prompt.

If the app tears down its navigator while a prompt is open (a logout that rebuilds MaterialApp, say), the prompt counts as dismissed and later requests carry on.

Your own dialog #

There are two levels.

dialogBuilder replaces the prompt's widget. The package still presents it, and data.presentation says how: for dialog your widget is the whole dialog, so return a Dialog; for bottomSheet and snackBar it goes inside the sheet or snack bar, so return just the content. That is the presentation the prompt actually gets, which may be a dialog when a snack bar couldn't be shown. 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(
    // Settings only once nothing missing can be asked for any more.
    onAllow: results.notAllowed.every(results.permanentlyDenied.contains)
        ? openSettings
        : request,
  ),
  child: const VideoCall(),
)

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

The gate's options apply to its own requests and to the built-in fallback (texts, theme, text direction). onChanged fires once per change, with the full result of the request that caused it. A gate without its own handler follows BaabaPermissions.instance, including when BaabaPermissions.init replaces it.

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.
});

// Every result of request(), requestMany() and openSettingsAndRecheck().
BaabaPermissions.instance.results.listen((r) { /* ... */ });

For your own "Enable in Settings" button:

final results = await BaabaPermissions.instance.openSettingsAndRecheck([AppPermission.camera]);
results[AppPermission.camera]!.changed; // did the user change it there?

It always waits for the user to come back before re-checking, whatever recheckAfterSettings says.

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)));

The history is kept per native permission, and within a session a permanent denial counts for every capability that shares that permission: after photos is refused on Android 12, videos (which also maps to storage there) reports permanentlyDenied too.

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. On iOS it also reports location as permanentlyDenied while Location Services are off, and locationAlways as permanentlyDenied once while-in-use is granted (a request still shows the upgrade prompt). You can also script rationale, service status, errors (statusError, requestError, or requestErrors to fail only the requests that include a given permission), 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. On iOS the "Allow Always" prompt appears once per install. iOS reports it as permanently denied before it is asked, and the package asks anyway. After a refusal, only Settings can change it.
  • Location Services off on iOS: iOS reports the location permission as denied while Location Services are switched off. The package reports serviceDisabled instead, and the flow asks the user to turn the service on.
  • 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.
  • Notifications below Android 13 have no prompt. They are either on or off in Settings, so notification comes back permanentlyDenied when they are off, and the Settings dialog is the only way forward.
  • 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 prompt's widget (see data.presentation)
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. A PermissionPlan with settingsOnly: true is never requested and goes straight to Settings
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. It also holds an on-device integration test (integration_test/device_flow_test.dart, driven by tool/device_driver.py over adb) that runs the flow against a real Android phone.
  • 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
160
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.

Repository (GitHub)
View/report issues

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