Runtime Permissions in .NET MAUI 10: Camera, Location, Notifications, and Android 14+ Granular Media
How to request runtime permissions in .NET MAUI 10 across camera, location, POST_NOTIFICATIONS, and Android 14+ granular media, with production code patterns for iOS and Android.
Runtime permissions in .NET MAUI 10 are requested through the cross-platform Permissions.RequestAsync<T>() API, which maps to ActivityCompat.requestPermissions on Android and the relevant AVCaptureDevice, CLLocationManager, or UNUserNotificationCenter request on iOS. You still have to declare each permission in AndroidManifest.xml and add a matching NS*UsageDescription to Info.plist. MAUI doesn't auto-inject either of those. Honestly, the bar in 2026 is higher than it used to be: Android 14+ split storage into granular media permissions, Android 13+ requires POST_NOTIFICATIONS, and iOS 17.4 tightened privacy manifests for any app touching device APIs.
I hit nearly every one of these footguns shipping a receipt-scanning app last year, so I'll flag the ones that cost me the most time as we go.
Use await Permissions.RequestAsync<Permissions.Camera>() on the main thread. Never call it from a background worker, or you'll get Unknown back silently.
Android 14, 15, and 16 deprecated READ_EXTERNAL_STORAGE. Request READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO, or READ_MEDIA_VISUAL_USER_SELECTED for the partial-access photo picker.
POST_NOTIFICATIONS must be requested at runtime on Android 13+. Your FCM pushes silently fail otherwise.
iOS requires a usage-description string for every device API in Info.plist, and since 2024 you also need matching reasons in PrivacyInfo.xcprivacy.
Background location (ACCESS_BACKGROUND_LOCATION) and Always authorization on iOS must be requested in a second step, after foreground location is granted.
For permissions MAUI doesn't ship out of the box (Bluetooth scan on Android 12+, full-screen intents, exact alarms), subclass Permissions.BasePlatformPermission.
The Permissions API and how it works
The Microsoft.Maui.ApplicationModel namespace exposes a static Permissions class with nested types for every cross-platform permission MAUI knows about: Camera, Microphone, Photos, PhotosAddOnly, LocationWhenInUse, LocationAlways, StorageRead, StorageWrite, NetworkState, Sensors, Bluetooth, Reminders, Speech, CalendarRead, CalendarWrite, ContactsRead, ContactsWrite, and Media. Each call returns a PermissionStatus: one of Unknown, Denied, Disabled, Granted, Restricted, or Limited.
In production code I always run the check-then-request pattern, because re-prompting a user who already granted the permission triggers a UI flash on iOS and an opaque immediate-return on Android.
public static async Task<bool> EnsureCameraAsync()
{
var status = await Permissions.CheckStatusAsync<Permissions.Camera>();
if (status == PermissionStatus.Granted)
return true;
if (Permissions.ShouldShowRationale<Permissions.Camera>())
{
// Explain WHY before the system dialog appears.
await Shell.Current.DisplayAlert(
"Camera access",
"We need the camera to scan receipts. You can revoke access any time in Settings.",
"Continue");
}
status = await Permissions.RequestAsync<Permissions.Camera>();
return status == PermissionStatus.Granted;
}
Three things teams trip on here. First, RequestAsync must be invoked on the UI thread. Wrap it in MainThread.InvokeOnMainThreadAsync if you're calling from a background service. Second, on iOS the system shows the prompt exactly once per install, and subsequent calls return the cached decision. Third, the Limited status only ever appears for Photos on iOS 14+ (when the user picked specific images) and for READ_MEDIA_VISUAL_USER_SELECTED on Android 14+. Treat it as "granted, but with a subset." For deeper background on capture flows, see our guide to capturing photos and picking files in .NET MAUI 10.
Android manifest declarations you must add
MAUI doesn't infer permission requirements from the C# permission types. You still need explicit <uses-permission> entries in Platforms/Android/AndroidManifest.xml. Forget the manifest, and RequestAsync will silently return Denied on the first call with no system dialog ever appearing. It's the single most common "my permission code doesn't work" report on Stack Overflow, and I've personally chased it for an hour more than once.
Here's a manifest fragment for an app that uses camera, foreground and background location, notifications, BLE, and the partial photo picker on Android 14+.
The maxSdkVersion="32" attribute on READ_EXTERNAL_STORAGE is critical. Google Play's pre-launch review now warns on apps that still declare it for Android 13+, and Android 15 outright revokes it. The neverForLocation flag on BLUETOOTH_SCAN tells the system you aren't deriving user location from beacons, which exempts you from the foreground-location requirement. Our BLE in .NET MAUI 10 guide walks through the Bluetooth permission matrix in depth.
iOS Info.plist and privacy manifest entries
On iOS, accessing any sensitive API without a matching usage-description string crashes the app on first use with a TCC violation, and App Store Connect refuses to validate the build. Every MAUI iOS project needs these strings inside Platforms/iOS/Info.plist.
<key>NSCameraUsageDescription</key>
<string>Scan receipts and capture profile photos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Record voice notes attached to your expenses.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Attach pictures of receipts from your library.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save exported reports to your photo library.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Tag receipts with the merchant location.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Track mileage between client visits in the background.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Pair with handheld card readers over BLE.</string>
Since spring 2024 Apple also requires a PrivacyInfo.xcprivacy file declaring "required reason" categories for any API that touches user defaults, file timestamps, system boot time, disk space, or active keyboards. Apple's privacy manifest documentation lists the exact reason codes. For the MAUI-specific workflow, see our walkthrough on configuring PrivacyInfo.xcprivacy in .NET MAUI 10.
Android 14+ granular media permissions
Android 13 (API 33) split the old READ_EXTERNAL_STORAGE blanket into three per-media-type permissions: READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, and READ_MEDIA_AUDIO. Android 14 (API 34) added a fourth, READ_MEDIA_VISUAL_USER_SELECTED, which surfaces a system-rendered picker letting the user grant access to a specific subset of images and videos rather than the full library.
The built-in Permissions.StorageRead type still maps to legacy READ_EXTERNAL_STORAGE and is effectively a no-op on API 33+. You need a custom subclass to request the new types correctly.
public class ReadMediaImagesPermission : Permissions.BasePlatformPermission
{
public override (string androidPermission, bool isRuntime)[] RequiredPermissions =>
OperatingSystem.IsAndroidVersionAtLeast(34)
? new[]
{
("android.permission.READ_MEDIA_IMAGES", true),
("android.permission.READ_MEDIA_VISUAL_USER_SELECTED", true)
}
: OperatingSystem.IsAndroidVersionAtLeast(33)
? new[] { ("android.permission.READ_MEDIA_IMAGES", true) }
: new[] { ("android.permission.READ_EXTERNAL_STORAGE", true) };
}
// Usage:
var status = await Permissions.RequestAsync<ReadMediaImagesPermission>();
if (status == PermissionStatus.Limited)
{
// User picked specific photos. Re-prompt on next visit if they want to add more
// by calling the picker again with EXTRA_PICK_IMAGES_MAX.
}
A Limited result on Android 14+ isn't a failure mode. Treat it the same as Granted for the current session, surface a "Pick more photos" affordance somewhere in your UI, and call the picker again rather than re-requesting the permission.
POST_NOTIFICATIONS on Android 13 and newer
Before Android 13, displaying a notification required no runtime prompt. Since API 33, every app needs the user to grant POST_NOTIFICATIONS explicitly, and the system silently drops every Firebase Cloud Messaging push (and every local notification) until the user agrees. This is the single highest-impact change for any MAUI app that relies on alerts.
MAUI doesn't ship a built-in permission for this either. The custom subclass is short.
public class PostNotificationsPermission : Permissions.BasePlatformPermission
{
public override (string androidPermission, bool isRuntime)[] RequiredPermissions =>
OperatingSystem.IsAndroidVersionAtLeast(33)
? new[] { ("android.permission.POST_NOTIFICATIONS", true) }
: Array.Empty<(string, bool)>();
}
public static async Task RequestNotificationsAsync()
{
#if ANDROID
if (OperatingSystem.IsAndroidVersionAtLeast(33))
{
var status = await Permissions.RequestAsync<PostNotificationsPermission>();
if (status != PermissionStatus.Granted)
{
// Surface an in-app banner. Never block the UI on this.
}
}
#elif IOS || MACCATALYST
var center = UNUserNotificationCenter.Current;
var (granted, _) = await center.RequestAuthorizationAsync(
UNAuthorizationOptions.Alert |
UNAuthorizationOptions.Badge |
UNAuthorizationOptions.Sound);
#endif
}
Timing matters as much as the call itself. Asking for notification permission on first launch yielded a 20-30% grant rate in my own telemetry. Asking after the user completes a meaningful action ("Get notified when your report is ready?") routinely pushed that above 70%. Our push notifications guide for .NET MAUI covers the full FCM and APNs wiring once you have the prompt working.
Background location on Android and iOS
Background location is the most heavily-policed permission on both stores. Google requires a separate Play Console declaration explaining why you need it, and Apple rejects apps whose continuous tracking can't be justified in the usage-description string. From an API perspective, both platforms force a two-step request: you must obtain foreground location first, and only then can you escalate to background access.
Behavior
Android 11+
iOS 13+
Two-step request required
Yes: foreground first, then ACCESS_BACKGROUND_LOCATION
Yes: WhenInUse first, then upgrade to Always
Settings-only escalation
Yes (second prompt opens system settings, not an in-app dialog)
No (second request shows a system dialog once)
Background-only permission UI
"Allow all the time" buried under "More options"
"Always Allow" requires the user to tap "Change to Always Allow" later
Foreground service required
Yes, needs foregroundServiceType="location"
Optional. Use Significant Location Changes for low-power
Store review scrutiny
Play Console background-location declaration form
App Review questionnaire on first submission
public static async Task<bool> EnsureBackgroundLocationAsync()
{
var fg = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (fg != PermissionStatus.Granted)
return false;
// On both platforms, request the upgraded scope as a separate call.
var bg = await Permissions.RequestAsync<Permissions.LocationAlways>();
return bg == PermissionStatus.Granted;
}
For real-time tracking pipelines and battery considerations, our geolocation and GPS tracking guide for .NET MAUI 10 covers geofencing, deferred location updates, and the Android foreground-service-of-type-location requirement that the OS enforces on API 34+.
Writing a custom Permission subclass
MAUI's built-in Permissions types cover the common cross-platform cases but leave gaps. BLUETOOTH_ADVERTISE, USE_FULL_SCREEN_INTENT, SCHEDULE_EXACT_ALARM, BODY_SENSORS_BACKGROUND, and Android 16's FOREGROUND_SERVICE_MEDIA_PROJECTION all need a custom subclass.
The pattern is identical: derive from Permissions.BasePlatformPermission and override RequiredPermissions with the manifest strings plus an isRuntime flag (true for anything Android classifies as dangerous, false for normal permissions).
public class ExactAlarmPermission : Permissions.BasePlatformPermission
{
public override (string androidPermission, bool isRuntime)[] RequiredPermissions =>
OperatingSystem.IsAndroidVersionAtLeast(33)
? new[] { ("android.permission.SCHEDULE_EXACT_ALARM", false) }
: Array.Empty<(string, bool)>();
public override Task<PermissionStatus> CheckStatusAsync()
{
#if ANDROID
if (OperatingSystem.IsAndroidVersionAtLeast(31))
{
var am = (Android.App.AlarmManager)Android.App.Application.Context
.GetSystemService(Android.Content.Context.AlarmService)!;
return Task.FromResult(am.CanScheduleExactAlarms()
? PermissionStatus.Granted
: PermissionStatus.Denied);
}
#endif
return Task.FromResult(PermissionStatus.Granted);
}
public override Task<PermissionStatus> RequestAsync()
{
#if ANDROID
// Exact alarms is a "special" permission. Only a settings deep-link is allowed.
var intent = new Android.Content.Intent(
Android.Provider.Settings.ActionRequestScheduleExactAlarm);
Platform.CurrentActivity?.StartActivity(intent);
#endif
return CheckStatusAsync();
}
}
Special permissions like exact alarm, full-screen intent, and "appear on top" can't be requested with a dialog. You have to deep-link the user into system settings. The override above is the production pattern I use: check the current status with the system manager, and on request open the relevant settings activity rather than calling the standard permission request.
Handling denied and permanently denied results
The hardest UX problem in this space is the difference between "user just said no" and "user said no twice and the system won't ask again." Android exposes this through ShouldShowRequestPermissionRationale. If it returns true after a denial, the user can be re-prompted. If it returns false after a denial, the request is dead until they manually flip the toggle in settings. iOS behaves the same way after the first denial: no second chance from a system dialog.
The pattern I use across every MAUI app on our team:
public static async Task<bool> RequestWithFallbackAsync<TPermission>(
string featureName)
where TPermission : Permissions.BasePermission, new()
{
var status = await Permissions.CheckStatusAsync<TPermission>();
if (status == PermissionStatus.Granted) return true;
status = await Permissions.RequestAsync<TPermission>();
if (status == PermissionStatus.Granted) return true;
if (status == PermissionStatus.Denied &&
!Permissions.ShouldShowRationale<TPermission>())
{
var goToSettings = await Shell.Current.DisplayAlert(
$"{featureName} blocked",
$"Please enable {featureName} in Settings to continue.",
"Open Settings",
"Not now");
if (goToSettings)
AppInfo.ShowSettingsUI();
}
return false;
}
AppInfo.ShowSettingsUI() deep-links to the app's own settings page on both platforms, which is the only path that survives a permanent denial. Detecting permanent denial differs subtly: on Android, use the ShouldShowRationale probe shown above. On iOS, treat any Denied result after the first prompt as permanent, since the system will never show the dialog again. Microsoft's MAUI Permissions documentation spells out the per-platform contract, and the patterns above wrap it into a single API surface your view-models can call without conditionals.
Frequently Asked Questions
Why isn't my permission request showing a dialog in .NET MAUI?
Three causes account for almost every silent failure: the permission isn't declared in AndroidManifest.xml or Info.plist, the request is being made from a non-UI thread (wrap it in MainThread.InvokeOnMainThreadAsync), or the user already denied it permanently and the system is returning the cached result. Check ShouldShowRationale to distinguish the third case.
Do I still need READ_EXTERNAL_STORAGE in .NET MAUI for Android 14?
No. On Android 13 and newer, request READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, or READ_MEDIA_AUDIO instead. Keep READ_EXTERNAL_STORAGE only with android:maxSdkVersion="32" in the manifest so older devices still work. Android 15 revokes the legacy permission entirely on API 35+.
How do I request POST_NOTIFICATIONS in .NET MAUI?
MAUI doesn't expose it directly. Create a subclass of Permissions.BasePlatformPermission whose RequiredPermissions returns ("android.permission.POST_NOTIFICATIONS", true) on API 33+ and an empty array below, then call Permissions.RequestAsync<YourSubclass>() from a user action, not from your MauiProgram or app-startup code.
What does PermissionStatus.Limited mean in .NET MAUI?
It means the user granted partial access. On iOS, specific photos from the library; on Android 14+, a subset selected through the READ_MEDIA_VISUAL_USER_SELECTED picker. Treat it as granted, but expose a "Select more" affordance in your UI so users can re-invoke the picker without going through Settings.
How do I deep-link the user to app settings in .NET MAUI?
Call AppInfo.ShowSettingsUI(). It opens your app's own settings page on Android and iOS, which is the only practical recovery path once a permission has been permanently denied. Always show a clear explanation first so the user understands why they're being sent to settings.
Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.
A working guide to shipping .NET MAUI 10 apps with Fastlane: match-based iOS signing, API-key auth for TestFlight, staged Play Store rollouts, and Fastfile lanes that survive real teams.
Bind iOS XCFrameworks and Android AARs in .NET MAUI 10 with Objective Sharpie, Metadata.xml transforms, and a single-NuGet package. Real errors, real fixes.
Stop the soft keyboard from covering inputs in .NET MAUI 10. Practical Android windowSoftInputMode setup, iOS keyboard avoidance, edge-to-edge Android 15 fixes, and a reusable KeyboardAvoidingView pattern that works across both platforms.