All device APIs in Mob follow a consistent pattern: call the function from a callback (returning the socket unchanged), then handle the result in handle_info/2. APIs never block the screen process.
Since 0.7.0, several capabilities ship as first-party plugins rather than in core: MobCamera (mob_camera), MobPhotos (mob_photos), MobLocation (mob_location), MobBiometric (mob_biometric), MobScanner (mob_scanner), and MobNotify (mob_notify). Activating one is the same two steps for each — add the dep, list it in mob.exs:
# mix.exs
{:mob_camera, "~> 0.1"}
# mob.exs
config :mob, :plugins, [:mob_camera]See the Plugins guide. Everything else on this page (haptics, clipboard, share, files, audio, motion, storage, web view, alerts) is core.
Permissions
Some capabilities require an OS permission before they can be used. Request permissions via Mob.Permissions.request/2. The result arrives asynchronously:
def mount(_params, _session, socket) do
socket = Mob.Permissions.request(socket, :camera)
{:ok, socket}
end
def handle_info({:permission, :camera, :granted}, socket) do
{:noreply, Mob.Socket.assign(socket, :camera_ready, true)}
end
def handle_info({:permission, :camera, :denied}, socket) do
{:noreply, Mob.Socket.assign(socket, :camera_ready, false)}
endCapabilities that require permission: :camera, :microphone, :photo_library, :location, :notifications
No permission needed: haptics, clipboard, share sheet, file picker.
Mob.Permissions.request/2is only half the picture. Each permission-gated capability also needs anInfo.plistusage description (iOS) andAndroidManifest.xmluses-permissionentry (Android). The defaultmix mob.newtemplate covers camera + microphone on iOS and most capabilities on Android, but leaves location, photo library, etc. for you to add explicitly. See permissions for the per-capability table, the iOS-specific gotchas, and a diagnostic checklist for "the dialog never appears".
Haptic feedback
Mob.Haptic.trigger/2 fires synchronously (no handle_info needed) and returns the socket:
def handle_event("tap", %{"tag" => "purchase"}, socket) do
socket = Mob.Haptic.trigger(socket, :success)
{:noreply, socket}
endFeedback types: :light, :medium, :heavy, :success, :error, :warning
iOS uses UIImpactFeedbackGenerator / UINotificationFeedbackGenerator. Android uses View.performHapticFeedback.
Clipboard
# Write to clipboard
def handle_event("tap", %{"tag" => "copy"}, socket) do
socket = Mob.Clipboard.write(socket, socket.assigns.code)
{:noreply, socket}
end
# Read from clipboard — result arrives in handle_info
def handle_event("tap", %{"tag" => "paste"}, socket) do
socket = Mob.Clipboard.read(socket)
{:noreply, socket}
end
def handle_info({:clipboard, :read, text}, socket) do
{:noreply, Mob.Socket.assign(socket, :pasted_text, text)}
endShare sheet
Opens the platform's native share sheet (iOS: UIActivityViewController, Android: ACTION_SEND):
def handle_event("tap", %{"tag" => "share"}, socket) do
socket = Mob.Share.sheet(socket, text: "Check out this app!", url: "https://example.com")
{:noreply, socket}
endOptions: :text, :url, :title
Camera
MobCamera ships in the mob_camera plugin (which also registers the :camera permission) — add the dep + activate in mob.exs. Requires :camera permission (and :microphone for video).
# Capture a photo
socket = MobCamera.capture_photo(socket)
socket = MobCamera.capture_photo(socket, quality: :medium)
# Record a video
socket = MobCamera.capture_video(socket)
socket = MobCamera.capture_video(socket, max_duration: 30)
# Results:
def handle_info({:camera, :photo, %{path: path, width: w, height: h}}, socket) do
{:noreply, Mob.Socket.assign(socket, :photo_path, path)}
end
def handle_info({:camera, :video, %{path: path, duration: seconds}}, socket) do
{:noreply, Mob.Socket.assign(socket, :video_path, path)}
end
def handle_info({:camera, :cancelled}, socket) do
{:noreply, socket}
endpath is a local temp file. Copy it to a permanent location before the next capture.
Photos
Browse and pick from the photo library. MobPhotos ships in the mob_photos plugin. Requires :photo_library permission.
socket = MobPhotos.pick(socket)
socket = MobPhotos.pick(socket, max: 5) # pick up to 5
def handle_info({:photos, :picked, photos}, socket) do
# photos is a list of %{path: path, width: w, height: h} maps
{:noreply, Mob.Socket.assign(socket, :photos, photos)}
end
def handle_info({:photos, :cancelled}, socket) do
{:noreply, socket}
endFiles
Open the system file picker:
socket = Mob.Files.pick(socket)
socket = Mob.Files.pick(socket, types: ["public.pdf", "public.text"]) # iOS UTI strings
socket = Mob.Files.pick(socket, types: ["application/pdf", "text/plain"]) # Android MIME types
def handle_info({:files, :picked, files}, socket) do
# files is a list of %{path: path, name: name, size: bytes} maps
{:noreply, Mob.Socket.assign(socket, :files, files)}
endPlatform note:
typesuses iOS UTI strings on iOS ("public.pdf") and MIME type strings on Android ("application/pdf"). To support both platforms with the same call, pass both forms — the platform ignores strings it doesn't recognise. See Platform-specific props for a cleaner pattern.
Camera preview
Display a live camera feed inline (no OS permission dialog for preview). The <CameraPreview> render-tree node lives in core, but the preview session API is MobCamera (mob_camera plugin):
def mount(_params, _session, socket) do
socket = MobCamera.start_preview(socket, facing: :back)
{:ok, socket}
end
def render(assigns) do
~MOB"""
<Column>
<CameraPreview facing={:back} weight={1} />
<Button text="Flip" on_tap={{self(), :flip}} />
</Column>
"""
end
def terminate(_reason, socket) do
MobCamera.stop_preview(socket)
:ok
endThe :camera_preview component requires an active preview session — call start_preview/2 before mounting and stop_preview/1 in terminate/2.
Audio recording
Requires :microphone permission.
socket = Mob.Audio.start_recording(socket)
socket = Mob.Audio.start_recording(socket, format: :aac, quality: :medium)
socket = Mob.Audio.stop_recording(socket)
def handle_info({:audio, :recorded, %{path: path, duration: seconds}}, socket) do
{:noreply, Mob.Socket.assign(socket, :recording, path)}
end
def handle_info({:audio, :error, reason}, socket) do
{:noreply, Mob.Socket.assign(socket, :error, reason)}
endRecording formats: :aac (default), :wav. Quality: :low, :medium (default), :high.
Audio playback
No permission needed. Plays local files or remote URLs.
socket = Mob.Audio.play(socket, "/path/to/clip.m4a")
socket = Mob.Audio.play(socket, path, loop: true, volume: 0.8)
socket = Mob.Audio.stop_playback(socket)
socket = Mob.Audio.set_volume(socket, 0.5) # adjust without stopping
def handle_info({:audio, :playback_finished, %{path: path}}, socket) do
{:noreply, socket}
end
def handle_info({:audio, :playback_error, %{reason: reason}}, socket) do
{:noreply, Mob.Socket.assign(socket, :error, reason)}
endiOS uses AVAudioPlayer / AVPlayer. Android uses MediaPlayer.
Location
MobLocation ships in the mob_location plugin. Requires :location permission.
# Single fix
socket = MobLocation.get_once(socket)
# Continuous updates
socket = MobLocation.start(socket)
socket = MobLocation.start(socket, accuracy: :high) # :high | :balanced | :low
socket = MobLocation.stop(socket)
def handle_info({:location, %{lat: lat, lon: lon, accuracy: acc, altitude: alt}}, socket) do
{:noreply, Mob.Socket.assign(socket, :location, %{lat: lat, lon: lon})}
end
def handle_info({:location, :error, reason}, socket) do
{:noreply, Mob.Socket.assign(socket, :location_error, reason)}
endiOS uses CLLocationManager. Android uses FusedLocationProviderClient.
Motion (accelerometer / gyroscope)
socket = Mob.Motion.start(socket)
socket = Mob.Motion.start(socket, interval_ms: 100)
socket = Mob.Motion.stop(socket)
def handle_info({:motion, %{ax: ax, ay: ay, az: az, gx: gx, gy: gy, gz: gz}}, socket) do
{:noreply, Mob.Socket.assign(socket, :motion, %{ax: ax, ay: ay, az: az})}
endBiometric authentication
MobBiometric ships in the mob_biometric plugin.
socket = MobBiometric.authenticate(socket, reason: "Confirm your identity")
def handle_info({:biometric, :success}, socket) do
{:noreply, Mob.Socket.assign(socket, :authenticated, true)}
end
def handle_info({:biometric, :failure, reason}, socket) do
{:noreply, socket}
endiOS uses Face ID / Touch ID. Android uses BiometricPrompt.
QR / barcode scanner
MobScanner ships in the mob_scanner plugin. Activate mob_camera alongside it — mob_camera owns the :camera permission the scanner needs (config :mob, :plugins, [:mob_camera, :mob_scanner]).
socket = MobScanner.scan(socket)
def handle_info({:scan, :result, %{type: type, value: value}}, socket) do
# type: :qr | :ean | :upc | etc.
{:noreply, Mob.Socket.assign(socket, :scanned, value)}
end
def handle_info({:scan, :cancelled}, socket) do
{:noreply, socket}
endNotifications
MobNotify ships in the mob_notify plugin — see its docs for the full API. Delivery (the {:notification, ...} and {:push_token, ...} messages below) is unchanged core behavior.
Requires :notifications permission.
Local notifications
# Schedule
MobNotify.schedule(socket,
id: "reminder_1",
title: "Time to check in",
body: "Open the app to see today's updates",
at: ~U[2026-04-16 09:00:00Z], # or delay_seconds: 60
data: %{screen: "reminders"}
)
# Cancel
MobNotify.cancel(socket, "reminder_1")
# Receive in handle_info (all app states: foreground, background, relaunched):
def handle_info({:notification, %{id: id, data: data, source: :local}}, socket) do
{:noreply, socket}
endPush notifications
See the Push Notifications guide for the full walkthrough — server setup, credential configuration, token handling, delivery lifecycle, and appearance options.
Quick reference:
# After :notifications permission is granted:
{:noreply, MobNotify.register_push(socket)}
# Receive the device token — store it server-side with the platform:
def handle_info({:push_token, platform, token}, socket) do
MyApp.PushTokens.upsert(socket.assigns.user_id, token, platform)
{:noreply, socket}
end
# Receive push notifications (foreground, background tap, or killed → tapped):
def handle_info({:notification, notif}, socket) do
# notif["source"] == "push", notif["data"] contains your custom payload
{:noreply, socket}
endTo send from your server, add mob_push to your server dependencies.
Storage
App-local file storage using named locations instead of raw paths. No permission needed.
# Resolve a location to its absolute path
path = Mob.Storage.dir(:documents) # persists, user-visible on iOS
path = Mob.Storage.dir(:cache) # persists until OS needs space
path = Mob.Storage.dir(:temp) # ephemeral, may be purged any time
path = Mob.Storage.dir(:app_support) # persists, hidden from user, backed up on iOS
# File operations
{:ok, files} = Mob.Storage.list(:documents) # returns full paths
{:ok, meta} = Mob.Storage.stat("/path/to/file") # %{name, path, size, modified_at}
{:ok, path} = Mob.Storage.write("/path/file.txt", "contents")
{:ok, data} = Mob.Storage.read("/path/file.txt")
{:ok, dest} = Mob.Storage.copy("/path/src.txt", :documents) # keeps basename
{:ok, dest} = Mob.Storage.move("/path/src.txt", "/path/dest.txt")
:ok = Mob.Storage.delete("/path/file.txt")
ext = Mob.Storage.extension("/tmp/clip.mp4") # => ".mp4"All operations that can fail return {:ok, value} | {:error, posix}. dir/1 raises on an unknown location atom.
For saving to the native media library (Camera Roll, Downloads), see Mob.Storage.Apple and Mob.Storage.Android.
WebView
Embed a native web view and communicate with it over a JS bridge. No permission needed.
def render(assigns) do
~MOB"""
<WebView url="https://example.com" allow={["https://example.com"]} show_url={true} weight={1} />
"""
end
# Send a message to Elixir from JS:
# window.mob.send({ event: "clicked", id: 42 })
def handle_info({:webview, :message, %{"event" => "clicked", "id" => id}}, socket) do
{:noreply, socket}
end
# A navigation attempt was blocked by the allow: whitelist
def handle_info({:webview, :blocked, url}, socket) do
{:noreply, socket}
endPush a message from Elixir into the page (calls window.mob.onMessage handlers):
socket = Mob.WebView.post_message(socket, %{type: "update", value: 42})Evaluate arbitrary JavaScript and receive the result:
socket = Mob.WebView.eval_js(socket, "document.title")
# Result arrives as:
def handle_info({:webview, :eval_result, result}, socket) do
{:noreply, socket}
endProps: :url (required), :allow (list of URL prefixes — blocks others), :show_url (native URL bar), :title (static label overriding :show_url), :width, :height.
Platform note: WebView is supported on both iOS and Android.
Alerts and toasts
Mob.Alert shows native dialogs and status messages. No permission needed.
Alert dialog
Centered modal for confirmations and errors (iOS: UIAlertController(.alert), Android: AlertDialog).
def handle_info({:tap, :delete}, socket) do
Mob.Alert.alert(socket,
title: "Delete item?",
message: "This cannot be undone.",
buttons: [
[label: "Delete", style: :destructive, action: :confirmed_delete],
[label: "Cancel", style: :cancel]
]
)
{:noreply, socket}
end
def handle_info({:alert, :confirmed_delete}, socket) do
{:noreply, do_delete(socket)}
end
def handle_info({:alert, :dismiss}, socket) do
{:noreply, socket}
endDismissing without tapping a button (e.g. Android back gesture) sends {:alert, :dismiss}.
Action sheet
Bottom-anchored list for choosing between actions (iOS: UIAlertController(.actionSheet), Android: list dialog).
Mob.Alert.action_sheet(socket,
title: "Share photo",
buttons: [
[label: "Save to Photos", action: :save],
[label: "Copy link", action: :copy],
[label: "Cancel", style: :cancel]
]
)
def handle_info({:alert, :save}, socket), do: {:noreply, save_photo(socket)}
def handle_info({:alert, :copy}, socket), do: {:noreply, copy_link(socket)}
def handle_info({:alert, :dismiss}, socket), do: {:noreply, socket}Toast
Ephemeral status message with no callback.
Mob.Alert.toast(socket, "Saved!")
Mob.Alert.toast(socket, "File uploaded", duration: :long)Duration: :short (default, ~2 s) or :long (~4 s). iOS renders a floating label overlay; Android uses Toast.
Button options
| Key | Values | Default |
|---|---|---|
:label | string | "" |
:style | :default, :cancel, :destructive | :default |
:action | atom — delivered as {:alert, atom} to handle_info/2 | :dismiss |