Packages
mob
0.6.17
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/screen.ex
defmodule Mob.Screen do
@moduledoc """
Behaviour and GenServer wrapper for a Mob screen.
Each screen runs as a supervised GenServer whose state is a `Mob.Socket`.
Putting one process per screen — instead of one big process for the whole
app — gives you isolation: a buggy `handle_event` crashes its own screen
and the supervisor restarts it without taking down navigation, audio,
background services, or the BEAM itself. Lifecycle callbacks (`mount`,
`render`, `handle_event`, `handle_info`, `terminate`) map directly to the
GenServer lifecycle, so the BEAM's existing concurrency tools (selective
receive, monitors, hot code push) work on screens without any Mob-specific
scaffolding.
## Usage
defmodule MyApp.CounterScreen do
use Mob.Screen
def mount(_params, _session, socket) do
{:ok, Mob.Socket.assign(socket, :count, 0)}
end
def render(assigns) do
%{
type: :column,
props: %{},
children: [
%{type: :text, props: %{text: "Count: \#{assigns.count}"}, children: []}
]
}
end
def handle_event("increment", _params, socket) do
{:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)}
end
end
## Starting a screen
{:ok, pid} = Mob.Screen.start_link(MyApp.CounterScreen, %{})
## Dispatching events
:ok = Mob.Screen.dispatch(pid, "increment", %{})
"""
@type socket :: Mob.Socket.t()
@callback mount(params :: map(), session :: map(), socket :: socket()) ::
{:ok, socket()} | {:error, term()}
@callback render(assigns :: map()) :: map()
@callback handle_event(event :: String.t(), params :: map(), socket :: socket()) ::
{:noreply, socket()} | {:reply, map(), socket()}
@callback handle_info(message :: term(), socket :: socket()) ::
{:noreply, socket()}
@callback terminate(reason :: term(), socket :: socket()) :: term()
@doc """
Serialise assigns for persistence. Return a plain map of the keys you want
restored on next launch. Defaults to the full assigns map minus any
non-serialisable values (PIDs, references, ports, functions).
Only called when `use Mob.Screen, vsn: N` (N > 0) or `persist: true`.
"""
@callback dump_state(assigns :: map()) :: map()
@doc """
Reconstruct assigns from a previously persisted map.
`stored_vsn` is the version that was current when the data was saved.
Match on it to migrate old shapes:
def load_state(1, stored), do: stored
def load_state(0, stored), do: Map.put(stored, :new_field, :default)
The returned map is merged into the socket's assigns after `mount/3` runs.
Only called when stored data exists.
"""
@callback load_state(stored_vsn :: non_neg_integer(), stored :: map()) :: map()
@doc """
Return a stable string key for storing this screen's state.
Implement when the same screen module holds per-user or parameterised state:
def screen_key(assigns), do: "\#{__MODULE__}:\#{assigns.user_id}"
Defaults to the module name string.
"""
@callback screen_key(assigns :: map()) :: String.t()
@optional_callbacks [handle_event: 3, handle_info: 2, terminate: 2, screen_key: 1]
defmacro __using__(opts) do
vsn = Keyword.get(opts, :vsn, 0)
persist = Keyword.get(opts, :persist, vsn > 0)
quote do
@behaviour Mob.Screen
import Mob.Sigil
def __mob_vsn__, do: unquote(vsn)
def __mob_persist__, do: unquote(persist)
def dump_state(assigns), do: assigns
def load_state(_vsn, stored), do: stored
def handle_info(_message, socket), do: {:noreply, socket}
def terminate(_reason, _socket), do: :ok
def handle_event(event, _params, _socket) do
raise "unhandled event #{inspect(event)} in #{inspect(__MODULE__)}. " <>
"Add a handle_event/3 clause to handle it."
end
defoverridable dump_state: 1, load_state: 2, handle_info: 2, terminate: 2, handle_event: 3
end
end
# ── GenServer wrapper ─────────────────────────────────────────────────────
use GenServer
@doc """
Start a screen process linked to the calling process.
`params` is passed as the first argument to `mount/3`.
"""
@spec start_link(module(), map(), keyword()) :: GenServer.on_start()
def start_link(screen_module, params, opts \\ []) do
GenServer.start_link(__MODULE__, {screen_module, params, :no_render, :android}, opts)
end
@doc """
Return the module of the currently active screen in the navigation stack.
Intended for testing and debugging.
"""
@spec get_current_module(pid()) :: module()
def get_current_module(pid) do
GenServer.call(pid, :get_current_module)
end
@doc """
Return the navigation history (list of `{module, socket}` pairs, head = most recent).
Intended for testing and debugging.
"""
@spec get_nav_history(pid()) :: [{module(), Mob.Socket.t()}]
def get_nav_history(pid) do
GenServer.call(pid, :get_nav_history)
end
@doc """
Start a screen as the root UI screen. Calls mount, renders the component tree
via `Mob.Renderer`, and calls `set_root` on the resulting view.
This is the main entry point for production use. `start_link/2` is for tests
(no NIF calls).
"""
@spec start_root(module(), map(), keyword()) :: GenServer.on_start()
def start_root(screen_module, params \\ %{}, opts \\ []) do
platform = :mob_nif.platform()
GenServer.start_link(__MODULE__, {screen_module, params, :render, platform}, opts)
end
@doc """
Dispatch a UI event to the screen process. Returns `:ok` synchronously once
the event has been processed and the state updated.
"""
@spec dispatch(pid(), String.t(), map()) :: :ok
def dispatch(pid, event, params) do
GenServer.call(pid, {:event, event, params})
end
@doc """
Return the current socket state of a running screen.
Intended for testing and debugging — not for production app logic.
"""
@spec get_socket(pid()) :: socket()
def get_socket(pid) do
GenServer.call(pid, :get_socket)
end
# ── GenServer callbacks ───────────────────────────────────────────────────
@impl GenServer
def init({screen_module, params, render_mode, platform}) do
socket = Mob.Socket.new(screen_module, platform: platform)
# Register under :mob_screen so C-layer mob_handle_back() can find us.
# Only in :render mode (production); tests use :no_render and run without a NIF.
if render_mode == :render, do: Process.register(self(), :mob_screen)
socket =
if render_mode == :render do
{t, r, b, l} = :mob_nif.safe_area()
Mob.Socket.assign(socket, :safe_area, %{top: t, right: r, bottom: b, left: l})
else
Mob.Socket.assign(socket, :safe_area, %{top: 0.0, right: 0.0, bottom: 0.0, left: 0.0})
end
case screen_module.mount(params, %{}, socket) do
{:ok, mounted_socket} ->
# Restore persisted assigns after mount so mount always runs cleanly.
# safe_area is re-applied from the current socket so a stale device
# inset from storage never wins over the live value.
loaded_socket = maybe_load_state(screen_module, mounted_socket)
socket =
if render_mode == :render do
# Check for a notification that launched the app from a killed state.
# Send it to self so it arrives via handle_info after init returns,
# consistent with foreground notification delivery.
case :mob_nif.take_launch_notification() do
:none -> :ok
json -> send(self(), {:mob_launch_notification, json})
end
do_render(screen_module, loaded_socket)
else
loaded_socket
end
if screen_module.__mob_persist__(), do: schedule_state_sync()
{:ok, {screen_module, socket, [], render_mode}}
{:error, reason} ->
{:stop, reason}
end
end
@impl GenServer
def handle_call({:event, event, params}, _from, {module, socket, nav_history, render_mode}) do
case module.handle_event(event, params, socket) do
{:noreply, new_socket} ->
{module, new_socket, nav_history, transition} =
apply_nav_action(module, new_socket, nav_history)
new_socket =
if render_mode == :render do
do_render(module, new_socket, transition)
else
new_socket
end
{:reply, :ok, {module, new_socket, nav_history, render_mode}}
{:reply, _response, new_socket} ->
{module, new_socket, nav_history, transition} =
apply_nav_action(module, new_socket, nav_history)
new_socket =
if render_mode == :render do
do_render(module, new_socket, transition)
else
new_socket
end
{:reply, :ok, {module, new_socket, nav_history, render_mode}}
end
end
@doc """
Apply a navigation action directly. Used by `Mob.Test` to drive navigation
programmatically without needing a UI event. Synchronous — the caller blocks
until the navigation (and re-render, in production mode) completes.
Valid actions mirror the `Mob.Socket` navigation functions:
- `{:push, dest, params}` — push a new screen
- `{:pop}` — pop to the previous screen
- `{:pop_to, dest}` — pop to a specific screen in history
- `{:pop_to_root}` — pop to the root of the current stack
- `{:reset, dest, params}` — replace the entire nav stack
"""
def handle_call({:navigate, nav_action}, _from, {module, socket, nav_history, render_mode}) do
socket = Mob.Socket.put_mob(socket, :nav_action, nav_action)
{new_module, new_socket, new_history, transition} =
apply_nav_action(module, socket, nav_history)
new_socket =
if render_mode == :render do
do_render(new_module, new_socket, transition)
else
new_socket
end
{:reply, :ok, {new_module, new_socket, new_history, render_mode}}
end
def handle_call(:get_socket, _from, {_module, socket, _nav_history, _mode} = state) do
{:reply, socket, state}
end
def handle_call(:inspect, _from, {module, socket, nav_history, _mode} = state) do
tree = module.render(socket.assigns)
info = %{
screen: module,
assigns: socket.assigns,
nav_history: Enum.map(nav_history, fn {mod, _} -> mod end),
tree: tree
}
{:reply, info, state}
end
def handle_call(:get_current_module, _from, {module, _socket, _nav_history, _mode} = state) do
{:reply, module, state}
end
def handle_call(:get_nav_history, _from, {_module, _socket, nav_history, _mode} = state) do
{:reply, nav_history, state}
end
# Notification that launched the app from a killed state.
# Decoded from JSON and re-dispatched as the standard {:notification, map} message.
# Hot-reload trigger sent by mob_dev after a dist push. Re-render with current code.
@impl GenServer
def handle_cast(:__mob_hot_reload__, {module, socket, nav_history, render_mode}) do
new_socket =
if render_mode == :render do
do_render(module, socket)
else
socket
end
{:noreply, {module, new_socket, nav_history, render_mode}}
end
@impl GenServer
def handle_info({:mob_launch_notification, json}, {module, socket, nav_history, render_mode}) do
notif = decode_notification_json(json)
handle_info({:notification, notif}, {module, socket, nav_history, render_mode})
end
# Android file/camera/photo/scan results arrive as {:mob_file_result, event, sub, json_binary}.
# Decode the JSON and re-dispatch as the user-facing event tuple.
def handle_info({:mob_file_result, event, sub, json_binary}, state) do
event_atom = String.to_atom(event)
sub_atom = String.to_atom(sub)
items =
case :json.decode(json_binary) do
list when is_list(list) ->
Enum.map(list, fn item when is_map(item) ->
Map.new(item, fn {k, v} -> {String.to_atom(k), v} end)
end)
_ ->
[]
end
msg =
case {event_atom, sub_atom} do
{:camera, :photo} ->
{:camera, :photo, List.first(items) || %{}}
{:camera, :video} ->
{:camera, :video, List.first(items) || %{}}
{:camera, :cancelled} ->
{:camera, :cancelled}
{:photos, :picked} ->
{:photos, :picked, items}
{:files, :picked} ->
{:files, :picked, items}
{:audio, :recorded} ->
{:audio, :recorded, List.first(items) || %{}}
{:storage, :saved_to_library} ->
item = List.first(items) || %{}
{:storage, :saved_to_library, item[:path]}
{:scan, :result} ->
item = List.first(items) || %{}
{:scan, :result, %{type: item[:type] |> to_atom_safe(), value: item[:value]}}
_ ->
{event_atom, sub_atom, items}
end
handle_info(msg, state)
end
# Peripheral.* events: a few carry JSON-encoded device records under tags
# like `:devices_json`, `:permission_granted_json`, etc. The transport's
# own module knows how to decode them; we dispatch through its
# `normalize_message/1` (a no-op for events without JSON payloads) before
# the user's handle_info sees them.
def handle_info({:peripheral, :vendor_usb, _tag, _session, _payload} = msg, state) do
normalized = Mob.VendorUsb.normalize_message(msg)
handle_info(normalized, state)
end
# System back gesture (Android hardware/swipe, iOS edge-pan).
# Handled here — before the user's handle_info — so every screen gets back
# navigation for free without implementing anything.
# If a WebView is present and has internal history, navigate within it first
# before popping the Mob nav stack.
def handle_info({:mob, :back}, {module, socket, nav_history, render_mode}) do
if render_mode == :render && :mob_nif.webview_can_go_back() do
:mob_nif.webview_go_back()
{:noreply, {module, socket, nav_history, render_mode}}
else
{module, new_socket, new_history, transition} =
if nav_history == [] do
if render_mode == :render, do: :mob_nif.exit_app()
{module, socket, [], :none}
else
apply_nav_action(module, Mob.Socket.put_mob(socket, :nav_action, {:pop}), nav_history)
end
new_socket =
if render_mode == :render do
do_render(module, new_socket, transition)
else
new_socket
end
{:noreply, {module, new_socket, new_history, render_mode}}
end
end
# List row selected — intercept before the user's handle_info and convert to
# a plain {:select, id, index} message so screens don't need to know about
# the internal {:tap, {:list, ...}} tag format.
def handle_info({:tap, {:list, id, :select, index}}, {module, socket, nav_history, render_mode}) do
{:noreply, new_socket} = module.handle_info({:select, id, index}, socket)
{module, new_socket, nav_history, transition} =
apply_nav_action(module, new_socket, nav_history)
new_socket =
if render_mode == :render do
do_render(module, new_socket, transition)
else
new_socket
end
{:noreply, {module, new_socket, nav_history, render_mode}}
end
# A component's state changed — re-render so the native view gets fresh props.
def handle_info({:component_changed, _id, _module}, {module, socket, nav_history, render_mode}) do
new_socket =
if render_mode == :render do
do_render(module, socket)
else
socket
end
{:noreply, {module, new_socket, nav_history, render_mode}}
end
# Periodic state sync — intercepted before the user's handle_info so the
# screen module never sees this internal message.
def handle_info(:__mob_sync_state__, {module, socket, nav_history, render_mode}) do
if module.__mob_persist__() do
Mob.ScreenState.dump(module, socket)
schedule_state_sync()
end
{:noreply, {module, socket, nav_history, render_mode}}
end
def handle_info(message, {module, socket, nav_history, render_mode}) do
{:noreply, new_socket} = module.handle_info(message, socket)
{module, new_socket, nav_history, transition} =
apply_nav_action(module, new_socket, nav_history)
new_socket =
if render_mode == :render do
do_render(module, new_socket, transition)
else
new_socket
end
{:noreply, {module, new_socket, nav_history, render_mode}}
end
defp to_atom_safe(nil), do: :qr
defp to_atom_safe(s) when is_binary(s), do: String.to_atom(s)
defp to_atom_safe(a) when is_atom(a), do: a
@impl GenServer
def terminate(reason, {module, socket, _nav_history, _render_mode}) do
if module.__mob_persist__(), do: Mob.ScreenState.dump(module, socket)
module.terminate(reason, socket)
end
# ── Navigation ────────────────────────────────────────────────────────────
# Inspect the socket's nav_action and execute it, returning
# {new_module, new_socket, new_nav_history, transition}.
defp apply_nav_action(module, socket, nav_history) do
case socket.__mob__.nav_action do
nil ->
{module, socket, nav_history, :none}
{:push, dest, params} ->
new_module = resolve_module(dest)
platform = socket.__mob__.platform
new_base =
Mob.Socket.new(new_module, platform: platform)
|> Mob.Socket.assign(:safe_area, socket.assigns.safe_area)
{:ok, mounted} = new_module.mount(params, %{}, new_base)
saved = {module, clear_nav_action(socket)}
{new_module, mounted, [saved | nav_history], :push}
{:pop} ->
case nav_history do
[{prev_module, prev_socket} | rest] ->
{prev_module, prev_socket, rest, :pop}
[] ->
{module, clear_nav_action(socket), [], :none}
end
{:pop_to_root} ->
case Enum.reverse(nav_history) do
[{root_module, root_socket} | _] ->
{root_module, root_socket, [], :pop}
[] ->
{module, clear_nav_action(socket), [], :none}
end
{:pop_to, dest} ->
target = resolve_module(dest)
case pop_to_module(nav_history, target) do
{:found, prev_module, prev_socket, rest} ->
{prev_module, prev_socket, rest, :pop}
:not_found ->
{module, clear_nav_action(socket), nav_history, :none}
end
{:reset, dest, params} ->
new_module = resolve_module(dest)
platform = socket.__mob__.platform
new_base =
Mob.Socket.new(new_module, platform: platform)
|> Mob.Socket.assign(:safe_area, socket.assigns.safe_area)
{:ok, mounted} = new_module.mount(params, %{}, new_base)
{new_module, mounted, [], :reset}
{:switch_tab, _tab} ->
# Tab switching is handled renderer-side; clear the action.
{module, clear_nav_action(socket), nav_history, :none}
end
end
defp resolve_module(dest) when is_atom(dest) do
case Code.ensure_loaded(dest) do
{:module, ^dest} ->
# dest is a loaded module — use it directly
dest
_ ->
# dest is a registered screen name atom — look up in registry
case Mob.Nav.Registry.lookup(dest) do
{:ok, module} ->
module
{:error, :not_found} ->
raise ArgumentError,
"Mob.Screen: unknown navigation destination #{inspect(dest)}. " <>
"Register it via Mob.Nav.Registry.register/2 or declare it in " <>
"your App.navigation/1."
end
end
end
defp pop_to_module([], _target), do: :not_found
defp pop_to_module([{module, socket} | rest], target) do
if module == target do
{:found, module, socket, rest}
else
pop_to_module(rest, target)
end
end
defp clear_nav_action(socket) do
Mob.Socket.put_mob(socket, :nav_action, nil)
end
# ── Helpers ───────────────────────────────────────────────────────────────
defp decode_notification_json(json) when is_binary(json) do
case :json.decode(json) do
map when is_map(map) ->
source =
case Map.get(map, "source", "local") do
"push" -> :push
_ -> :local
end
data =
case Map.get(map, "data") do
d when is_map(d) ->
Map.new(d, fn {k, v} -> {String.to_atom(k), v} end)
_ ->
%{}
end
%{
id: Map.get(map, "id"),
title: Map.get(map, "title"),
body: Map.get(map, "body"),
data: data,
source: source
}
_ ->
%{source: :local, data: %{}}
end
end
# ── State persistence ─────────────────────────────────────────────────────
@state_sync_interval_ms 30_000
defp schedule_state_sync do
Process.send_after(self(), :__mob_sync_state__, @state_sync_interval_ms)
end
defp maybe_load_state(module, socket) do
if module.__mob_persist__() do
case Mob.ScreenState.load(module, socket) do
{:ok, stored_vsn, raw} ->
restored = module.load_state(stored_vsn, raw)
socket
|> Mob.Socket.assign(restored)
|> Mob.Socket.assign(:safe_area, socket.assigns.safe_area)
:not_found ->
socket
end
else
socket
end
end
# ── Render pipeline ───────────────────────────────────────────────────────
defp do_render(module, socket, transition \\ :none) do
platform = socket.__mob__.platform
list_renderers = Map.get(socket.__mob__, :list_renderers, %{})
socket = ensure_safe_area(socket, platform)
{tree, active_component_keys} =
module.render(socket.assigns)
|> Mob.List.expand(list_renderers, self())
|> Mob.Component.expand(self(), platform)
Mob.ComponentRegistry.reconcile(self(), active_component_keys)
{:ok, token} = Mob.Renderer.render(tree, platform, :mob_nif, transition)
Mob.Socket.put_root_view(socket, token)
end
defp ensure_safe_area(socket, platform) do
if Map.has_key?(socket.assigns, :safe_area) do
socket
else
safe_area =
if platform == :ios do
{t, r, b, l} = :mob_nif.safe_area()
%{top: t, right: r, bottom: b, left: l}
else
%{top: 0.0, right: 0.0, bottom: 0.0, left: 0.0}
end
Mob.Socket.assign(socket, :safe_area, safe_area)
end
end
end