baaba_permission_handler 1.0.0
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 #
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, 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
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.
- 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. - 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 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, andPermissionGate. - 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.