Mob.Files (mob v0.7.2)

Copy Markdown View Source

System file picker. Opens the OS document picker (Files app on iOS, SAF on Android).

No permission required — the user explicitly selects files.

Results arrive as:

handle_info({:files, :picked,    items},   socket)
handle_info({:files, :cancelled},           socket)

Each item in items is:

%{path: "/tmp/mob_file_xxx.pdf", name: "report.pdf",
  mime: "application/pdf", size: 102400}

iOS: UIDocumentPickerViewController. Android: OpenMultipleDocuments.

Filtering by file type

Pass :types to pick/2 to limit what the picker offers:

Mob.Files.pick(socket, types: ["livemd"])              # one extension
Mob.Files.pick(socket, types: [:images, :pdf])         # semantic groups
Mob.Files.pick(socket, types: [{:mime, "application/pdf"}])

Each entry is one of:

  • an extension string — "livemd" or ".livemd" (the leading dot is optional). This is the common case and matches how apps think about the files they own.
  • a MIME string — any value containing a slash, e.g. "application/pdf" or a wildcard "text/*".
  • a semantic atom — :images, :video, :audio, :pdf, :text.
  • {:extension, ext} / {:mime, type} / {:uti, id} for an explicit kind. {:uti, "dev.livebook.livemd"} targets an iOS Uniform Type Identifier directly.
  • :any (the default) — offer everything.

Platform asymmetry — read this before relying on it

The two platforms filter differently, and a custom extension exposes the gap:

  • iOS filters by UTType, which it can derive from an extension even for an unregistered custom type. So types: ["livemd"] strictly limits the picker to .livemd files.
  • Android SAF filters by MIME type only — it has no extension filter. A custom extension with no registered MIME (.livemd) cannot be narrowed at the picker, so the picker stays wide and the user can still tap the "wrong" file.

Because of this, enforce the filter on the result too. pick/2 narrows the picker where the OS allows; accept/2 rejects anything that slipped through where it doesn't, giving consistent semantics on both platforms:

def handle_info({:files, :picked, items}, socket) do
  case Mob.Files.accept(items, ["livemd"]) do
    [%{path: path} | _] -> {:noreply, open(socket, path)}
    [] -> {:noreply, put_flash(socket, :error, "Please choose a .livemd file")}
  end
end

accept/2 matches on the result's name/mime, so it enforces extensions, MIME types, and semantic groups. A {:uti, _} spec can't be checked from the result and is treated as already-enforced by the iOS picker.

"Open with" — files handed to us by another app

When the user opens a file into the app from elsewhere — e.g. a .livemd emailed to them and tapped — the OS launches (or foregrounds) the app with that file, provided the app declares the document type. This is a separate, build-time mechanism from the runtime :types picker filter above:

  • iOS: CFBundleDocumentTypes (+ an imported UTI) in Info.plist, and an application:openURL:options: handler that calls mob_handle_opened_url.
  • Android: an <intent-filter> for ACTION_VIEW / ACTION_SEND matching the mime type / extension; the Mob activity forwards it automatically.

Retrieve it with take_opened_document/0 from your root screen's mount/3.

Summary

Types

A single entry in the :types list. See the moduledoc for the full forms.

Functions

Keep only the items in items that satisfy types (see matches?/2).

True if a picked/opened item map satisfies types.

Normalize a :types value into the canonical envelope sent to the native picker — a list of %{"kind" => kind, "value" => value} maps.

Open the system document picker.

Return the document another app asked us to open, or :none.

Types

type_spec()

@type type_spec() ::
  :any
  | :images
  | :video
  | :audio
  | :pdf
  | :text
  | String.t()
  | {:extension, String.t()}
  | {:mime, String.t()}
  | {:uti, String.t()}

A single entry in the :types list. See the moduledoc for the full forms.

Functions

accept(items, types)

@spec accept([map()], [type_spec()] | type_spec()) :: [map()]

Keep only the items in items that satisfy types (see matches?/2).

Use this in your {:files, :picked, items} handler to enforce a type filter the picker could not (notably a custom extension on Android SAF).

matches?(item, types)

@spec matches?(map(), [type_spec()] | type_spec()) :: boolean()

True if a picked/opened item map satisfies types.

Returns true when types is empty/:any, or when none of the specs are checkable from the result (e.g. only {:uti, _} hints, which rely on the iOS picker having already filtered). Otherwise the item must match at least one spec by extension, MIME, or semantic group.

normalize_types(types)

@spec normalize_types([type_spec()] | type_spec()) :: [
  %{required(String.t()) => String.t()}
]

Normalize a :types value into the canonical envelope sent to the native picker — a list of %{"kind" => kind, "value" => value} maps.

:any (or "*/*", anywhere in the list) collapses the whole filter to [], meaning "offer everything". Exposed so the wire contract with the iOS/Android native layers is testable and documented in one place.

pick(socket, opts \\ [])

@spec pick(
  Mob.Socket.t(),
  keyword()
) :: Mob.Socket.t()

Open the system document picker.

Pass types: [...] to limit what's offered (see the moduledoc). Defaults to :any. Results arrive asynchronously as {:files, :picked, items} / {:files, :cancelled} to the calling process.

take_opened_document()

@spec take_opened_document() :: map() | :none

Return the document another app asked us to open, or :none.

Call once from your root screen's mount/3. The item has the same shape as pick/2 results:

%{path: "/tmp/demo.livemd", name: "demo.livemd",
  mime: "text/markdown", size: 1234}

The copied file lives in the app's tmp dir, so read or move it promptly. This call also registers the calling process to receive any file opened later while the app is already running, delivered as:

handle_info({:files, :opened, item}, socket)

Returns :none off-device or when nothing is pending. See the moduledoc for the platform manifest/Info.plist wiring "open with" requires.