baaba_permission_handler 1.1.0
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 #
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 orBuildContextneeded. Works withMaterialApp,GetMaterialAppandCupertinoApp. - Multiple permissions in one call:
[AppPermission.camera, AppPermission.microphone].request(), grouped into one flow. - OS versions handled for you. Ask for
AppPermission.photosand 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
PermissionGatewidget, 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:
- Returns at once, with no UI, if the permission is already allowed or doesn't exist on this OS.
- Explains the request first if the user denied it before. When to explain is configurable.
- Shows the system prompt.
- If the user denies it, offers to ask again.
- 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.
- If location services or Bluetooth are switched off, offers to open that switch.
The import re-exports
Permission,PermissionStatusandServiceStatusfrompermission_handler, so you don't need that package as a direct dependency.
Table of contents #
- Platform setup
- Quick start
- Permissions
- Results and states
- Several permissions at once
- The flow in detail
- Customization
- PermissionGate
- Watching for changes
- Android's hidden permanent denial
- Observability
- Testing
- Gotchas
- Configuration reference
- Additional information
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,SMSand 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.plistkeys at build time.notificationis on by default.criticalAlertsneedsexport PERMISSION_CRITICAL_ALERTS=1before building. After changing keys, runrm -rf ~/Library/Developer/Xcode/DerivedDataonce. -
CocoaPods: add macros to the
post_installblock inios/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 endKeep 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, andnavigatorKey:/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
ScaffoldMessengerabove it, or noScaffoldon 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
permanentlyDeniedand only Settings can change it. That's whyRationaleMode.beforeFirstRequestis 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: 1gives 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
serviceDisabledinstead, and the flow asks the user to turn the service on. - Storage on Android 13+ can't be granted. Use
photos,videosoraudio, which pick the right permission on every version.storageon 13+ comes backnotApplicableinstead of permanently denied. - Instant denial. If a request comes back denied with no prompt, the permission is missing from
AndroidManifest.xml,Info.plistor the Podfile. Debug builds print a hint. - Notifications below Android 13 have no prompt. They are either on or off in Settings, so
notificationcomes backpermanentlyDeniedwhen 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_pickeron 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, andPermissionGate. It also holds an on-device integration test (integration_test/device_flow_test.dart, driven bytool/device_driver.pyover 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
PermissionResultprintout.result.toString()includes the state and what the flow did. - Changelog: see CHANGELOG.md.
- License: MIT, see LICENSE.