Single source of truth for the OS-level permissions Mob exposes, the
manifest / Info.plist entries each one requires, and the
platform-specific gotchas that aren't covered by the runtime API alone.
If you're hitting "the dialog never appears" or "I called the NIF and nothing happened", this is the first place to look.
TL;DR
- Call
Mob.Permissions.request(socket, :capability)from your screen. The result arrives as
handle_info({:permission, :capability, :granted | :denied}, socket).- iOS additionally needs the matching
NS*UsageDescriptionkey inios/Info.plist. Without it, the dialog is silently suppressed and you get nothing — no event, no error. - Android additionally needs the matching
uses-permissionline inAndroidManifest.xml. Themob.newtemplate ships most of these already; if you added a feature after generating the project, double-check.
The per-capability table
Mob.Permissions cap | iOS Info.plist key | Android uses-permission | Notes |
|---|---|---|---|
:camera | NSCameraUsageDescription | android.permission.CAMERA | Required by Mob.Camera. CameraPreview also needs the plist key but does not call Mob.Permissions.request/2 — request explicitly before mounting it. |
:microphone | NSMicrophoneUsageDescription | android.permission.RECORD_AUDIO | Required by Mob.Audio.start_recording/2 and by Mob.Camera.capture_video/2. |
:photo_library | NSPhotoLibraryUsageDescription | API 33+: READ_MEDIA_IMAGES + READ_MEDIA_VIDEO. API ≤32: READ_EXTERNAL_STORAGE. | Required by Mob.Photos.pick/2. |
:location | NSLocationWhenInUseUsageDescription | ACCESS_FINE_LOCATION (high accuracy) and/or ACCESS_COARSE_LOCATION (low accuracy). | See iOS notes below — the dialog timing is unusual. |
:notifications | (none — handled by UNUserNotificationCenter) | API 33+: android.permission.POST_NOTIFICATIONS | iOS shows the dialog the first time request/2 runs. Android API ≤32 doesn't need a permission at all (notifications are user-controllable in Settings). |
Capabilities that need no runtime permission on either platform and do not appear in the table:
Mob.Haptic,Mob.Clipboard,Mob.Share,Mob.Files.pick/2,Mob.Toast,Mob.Alert,Mob.WebView,Mob.Motion,Mob.Biometric(uses biometric prompt UI but does not require a permission grant),Mob.Storage(app-local paths only).
Capabilities that need an Info.plist or manifest entry without going
through Mob.Permissions.request/2:
| Operation | iOS Info.plist key | Android |
|---|---|---|
Mob.Storage.save_to_photo_library/2 | NSPhotoLibraryAddUsageDescription | Same READ_MEDIA_* family as :photo_library on API 33+. |
Mob.Audio.play/2 (no permission) | none | none |
Mob.Camera.start_preview/2 (no permission for the preview; capture still needs :camera) | NSCameraUsageDescription | CAMERA |
What the mob.new template ships by default
If you generate a fresh project with mix mob.new, the template emits:
ios/Info.plist—NSCameraUsageDescriptionandNSMicrophoneUsageDescription. Nothing else.android/app/src/main/AndroidManifest.xml—CAMERA,RECORD_AUDIO,ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATION,READ_MEDIA_IMAGES,READ_MEDIA_VIDEO,READ_EXTERNAL_STORAGE(API ≤32 only),POST_NOTIFICATIONS,VIBRATE,FOREGROUND_SERVICE,INTERNET,RECEIVE_BOOT_COMPLETED.
So out-of-the-box your project covers camera + microphone on both
platforms, plus everything Android needs for the other capabilities.
Anything iOS-side beyond camera + mic needs you to add the
Info.plist key yourself before the first time you call that
capability. The most common ones to add:
<key>NSLocationWhenInUseUsageDescription</key>
<string>MyApp shows your location to ...</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>MyApp lets you pick photos from your library.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>MyApp saves captures to your photo library.</string>If you ship without the key, iOS won't even log the missing-key error
in any obvious place — the dialog just silently doesn't appear, and
the underlying request*Authorization call no-ops. Symptom looks
identical to "the user denied permission" except no denied event
ever arrives.
iOS-specific notes
iOS location extras
Apple's CLLocationManager couples permission and updates more
tightly than the other capabilities. Mob exposes both paths:
Mob.Permissions.request(socket, :location)callsrequestWhenInUseAuthorizationon a dedicatedCLLocationManagerand reports the user's actual choice as{:permission, :location, :granted | :denied}once the dialog is dismissed (or immediately if the permission was previously decided).Mob.Location.get_once/1andMob.Location.start/2also trigger the dialog ifrequest/2wasn't called yet. The dialog is one-shot per app install — subsequent calls short-circuit with the cached authorization.If the user denies, two events flow:
Mob.Permissions.request/2's caller hears{:permission, :location, :denied}.Mob.Location.get_once/1/start/2's caller hears{:location, :error, :permission_denied}(via thelocationManagerDidChangeAuthorization:callback). This means a screen that skippedrequest/2and went straight toget_oncestill has a way to break out of the "waiting for fix…" state.
The
Allow Oncebutton on iOS counts as:grantedfor the current run of the app. The next launch will prompt again.Authorization can change mid-session — the user pops out to Settings and revokes. The delegate fires
{:location, :error, :permission_denied}when that happens; surface it in your screen if you care about long-running tracking sessions.
Camera + microphone
These go through AVFoundation's requestAccessForMediaType, which
fires the dialog at request/2 time. No additional gotchas — make
sure the plist key is present, the dialog appears, you get a typed
{:permission, :camera | :microphone, ...} event.
Photo library
PHPhotoLibrary.requestAuthorizationForAccessLevel:PHAccessLevelReadWrite
is what :photo_library invokes. iOS treats
PHAuthorizationStatusLimited (the user picked "Selected Photos…")
as :granted from your screen's perspective — the rest of Mob.Photos
deals with the limited-access set transparently.
Notifications
Uses UNUserNotificationCenter requestAuthorizationWithOptions:. Asks
for alert, sound, and badge in one shot. The current implementation
returns :granted if the user granted any of the three.
Android-specific notes
Foreground vs background location
Mob only requests foreground location (ACCESS_FINE_LOCATION /
ACCESS_COARSE_LOCATION). If your app needs to keep tracking while
backgrounded, you need to additionally declare
ACCESS_BACKGROUND_LOCATION in the manifest and request it through
a custom flow — Mob.Permissions.request/2 doesn't surface that
capability today.
Notifications on Android ≤ 12
Pre-API-33, posting a notification does not require a runtime
permission grant — the user controls it via Settings. The
{:permission, :notifications, :granted} event will still fire from
request/2 so your screen code stays portable.
Storage and photos
API 33+ replaced the single READ_EXTERNAL_STORAGE permission with
per-media-type permissions (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO).
The mob.new template declares all of them so the photo picker works
across API levels. Saving with Mob.Storage.save_to_photo_library/2
uses MediaStore, which doesn't require a permission on API 29+ at
all — the manifest declarations are only for the read path.
Re-requesting after denial
Calling Mob.Permissions.request/2 again after the user denied
does not re-show the dialog on either platform — that's an OS
restriction. The event still arrives (with :denied), so your screen
can re-render an explanation. To actually re-prompt the user, they
have to go through system Settings:
- iOS: Settings → MyApp → \<capability>
- Android: Settings → Apps → MyApp → Permissions → \<capability>
A common UX is: on :denied, show a "Permission needed — open
Settings" CTA. Mob.OpenUrl.open/2 with the appropriate scheme
("app-settings:" on iOS, Intent.ACTION_APPLICATION_DETAILS_SETTINGS
on Android — surfaced via Mob.System.open_app_settings/1 if your
project has it; otherwise call the manifest-permitted scheme directly)
will jump straight to the right settings page.
Diagnosing a stuck request
Symptom: you called Mob.Permissions.request/2 (or a capability
function), no dialog appears, no :permission/:error event ever
arrives.
Run through this in order:
- iOS plist key present? Open
ios/Info.plist(or the rendered bundle inside the.app) and confirm theNS*UsageDescriptionfor the capability is there. The single most common cause. - Android manifest entry present? Open
android/app/src/main/AndroidManifest.xml. If you added the feature post-mob.new, the entry may be missing. - Already denied at the OS level? iOS: Settings →
MyApp → \<cap>. Android: Settings → Apps → MyApp →
Permissions. A previously-denied permission won't re-prompt;
request/2still fires the:deniedevent, so check yourhandle_info({:permission, :cap, :denied}, _)clause exists. - The screen process actually still alive? If your screen
crashed before
handle_info/2ran, the message is lost. Checkadb logcator the iOS device console for a crash earlier in the pipeline. - You're calling
request/2from a non-screen process.enif_sendtargets the calling pid; if a Task orspawnran the request, its inbox is where the event went. Always request from the screen GenServer.
Cross-platform pattern
def mount(_params, _session, socket) do
# Cheap and idempotent on both platforms. Safe to call even if
# you're not yet ready to use the capability — the response
# informs whether the action button below should be enabled.
socket = Mob.Permissions.request(socket, :location)
{:ok, Mob.Socket.assign(socket, permission: :pending)}
end
def handle_info({:permission, :location, :granted}, socket) do
{:noreply, Mob.Socket.assign(socket, permission: :granted)}
end
def handle_info({:permission, :location, :denied}, socket) do
# Render a "needs permission — open Settings" CTA.
{:noreply, Mob.Socket.assign(socket, permission: :denied)}
end