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*UsageDescription key in ios/Info.plist. Without it, the dialog is silently suppressed and you get nothing — no event, no error.
  • Android additionally needs the matching uses-permission line in AndroidManifest.xml. The mob.new template ships most of these already; if you added a feature after generating the project, double-check.

The per-capability table

Mob.Permissions capiOS Info.plist keyAndroid uses-permissionNotes
:cameraNSCameraUsageDescriptionandroid.permission.CAMERARequired by Mob.Camera. CameraPreview also needs the plist key but does not call Mob.Permissions.request/2 — request explicitly before mounting it.
:microphoneNSMicrophoneUsageDescriptionandroid.permission.RECORD_AUDIORequired by Mob.Audio.start_recording/2 and by Mob.Camera.capture_video/2.
:photo_libraryNSPhotoLibraryUsageDescriptionAPI 33+: READ_MEDIA_IMAGES + READ_MEDIA_VIDEO. API ≤32: READ_EXTERNAL_STORAGE.Required by Mob.Photos.pick/2.
:locationNSLocationWhenInUseUsageDescriptionACCESS_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_NOTIFICATIONSiOS 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:

Capabilities that need an Info.plist or manifest entry without going through Mob.Permissions.request/2:

OperationiOS Info.plist keyAndroid
Mob.Storage.save_to_photo_library/2NSPhotoLibraryAddUsageDescriptionSame READ_MEDIA_* family as :photo_library on API 33+.
Mob.Audio.play/2 (no permission)nonenone
Mob.Camera.start_preview/2 (no permission for the preview; capture still needs :camera)NSCameraUsageDescriptionCAMERA

What the mob.new template ships by default

If you generate a fresh project with mix mob.new, the template emits:

  • ios/Info.plistNSCameraUsageDescription and NSMicrophoneUsageDescription. Nothing else.
  • android/app/src/main/AndroidManifest.xmlCAMERA, 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:

  1. Mob.Permissions.request(socket, :location) calls requestWhenInUseAuthorization on a dedicated CLLocationManager and reports the user's actual choice as {:permission, :location, :granted | :denied} once the dialog is dismissed (or immediately if the permission was previously decided).

  2. Mob.Location.get_once/1 and Mob.Location.start/2 also trigger the dialog if request/2 wasn't called yet. The dialog is one-shot per app install — subsequent calls short-circuit with the cached authorization.

  3. 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 the locationManagerDidChangeAuthorization: callback). This means a screen that skipped request/2 and went straight to get_once still has a way to break out of the "waiting for fix…" state.
  4. The Allow Once button on iOS counts as :granted for the current run of the app. The next launch will prompt again.

  5. 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:

  1. iOS plist key present? Open ios/Info.plist (or the rendered bundle inside the .app) and confirm the NS*UsageDescription for the capability is there. The single most common cause.
  2. Android manifest entry present? Open android/app/src/main/AndroidManifest.xml. If you added the feature post-mob.new, the entry may be missing.
  3. Already denied at the OS level? iOS: Settings → MyApp → \<cap>. Android: Settings → Apps → MyApp → Permissions. A previously-denied permission won't re-prompt; request/2 still fires the :denied event, so check your handle_info({:permission, :cap, :denied}, _) clause exists.
  4. The screen process actually still alive? If your screen crashed before handle_info/2 ran, the message is lost. Check adb logcat or the iOS device console for a crash earlier in the pipeline.
  5. You're calling request/2 from a non-screen process. enif_send targets the calling pid; if a Task or spawn ran 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