Packages
mob_dev
0.6.17
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.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
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.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
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.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/bench/preflight.ex
defmodule MobDev.Bench.Preflight do
@moduledoc """
Pre-run checklist for the iOS battery bench.
Walks through the things that have to be right before locking the screen
and running for 30 minutes. Each check returns `:ok | {:error, message}`.
Goals:
- Catch the misconfigurations that would invalidate a run *before* the run
starts (saves ~30 min of wasted bench time).
- Tell the user exactly what's wrong and how to fix it.
- Be testable — each check is a pure function returning a tagged result.
## Checks performed
1. **USB / hardware UDID** — at least one of: USB-connected device
reachable via `idevice_id -l`, or a configured `:hw_udid`.
2. **App installed** — bundle id appears in `xcrun devicectl device info apps`.
3. **BEAM reachable** — Node.connect succeeds.
4. **RPC responsive** — `rpc.call(node, :erlang, :node, [])` returns within
2 seconds. Distinguishes "BEAM up but suspended" from "fully alive".
5. **NIF version** — `mob_nif:battery_level/0` is exported. (Indicates the
installed app build is recent enough.)
6. **Background NIF** — `mob_nif:background_keep_alive/0` is exported.
Required for screen-off bench mode.
Each check is independent — failure in (3) doesn't skip (5); we run them
all and report a complete picture.
"""
alias MobDev.Bench.Probe
@typedoc "Result for a single check."
@type check_result :: {:ok, String.t()} | {:error, String.t()}
@doc """
Run all preflight checks and return a list of `{name, result}` tuples in
the order they were run.
Common options:
- `:platform` — `:ios` (default) or `:android` — selects platform-specific
`hardware` and `app_installed` checks
- `:node` — node atom (required for BEAM checks)
- `:cookie` — cookie atom
- `:bundle_id` — app bundle id
- `:host` — IP/host for EPMD (default: derive from node)
- `:require_keep_alive` — boolean, default true (set false for screen-on bench)
iOS-specific:
- `:device_id` — devicectl identifier (CoreDevice UUID)
- `:hw_udid` — hardware UDID for USB checks
Android-specific:
- `:adb_serial` — ADB serial / IP:port for `adb` checks
"""
@spec run(keyword()) :: [{atom(), check_result()}]
def run(opts) do
platform = Keyword.get(opts, :platform, :ios)
[
{:hardware, check_hardware(platform, opts)},
{:app_installed, check_app_installed(platform, opts)},
{:beam_reachable, check_beam_reachable(opts)},
{:rpc_responsive, check_rpc_responsive(opts)},
{:nif_version, check_nif_version(opts)},
{:keep_alive_nif,
if(opts[:require_keep_alive] != false,
do: check_keep_alive_nif(opts),
else: {:ok, "skipped"}
)}
]
end
@doc """
Returns true if every result is `{:ok, _}`. Stricter overall check than
examining individual results — useful for deciding whether to abort.
"""
@spec all_ok?([{atom(), check_result()}]) :: boolean()
def all_ok?(results) do
Enum.all?(results, fn
{_name, {:ok, _}} -> true
_ -> false
end)
end
@doc """
Format the results as a multi-line string with ✓/✗ markers.
"""
@spec pretty([{atom(), check_result()}]) :: String.t()
def pretty(results) do
Enum.map_join(results, "\n", fn
{name, {:ok, msg}} -> " ✓ #{format_name(name)} — #{msg}"
{name, {:error, msg}} -> " ✗ #{format_name(name)} — #{msg}"
end)
end
defp format_name(name) do
name |> Atom.to_string() |> String.replace("_", " ")
end
# ── Individual checks ────────────────────────────────────────────────────
# ── Hardware check (platform-dispatched) ──────────────────────────────
@doc false
@spec check_hardware(:ios | :android, keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_hardware(platform, opts) do
case platform do
:ios -> check_hardware_ios(opts)
:android -> check_hardware_android(opts)
_ -> {:error, "unknown platform: #{inspect(platform)}"}
end
end
# Backward-compat — old single-arg version defaults to iOS.
@doc false
@spec check_hardware(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_hardware(opts), do: check_hardware(:ios, opts)
defp check_hardware_ios(opts) do
cond do
is_binary(opts[:hw_udid]) ->
{:ok, "hardware UDID provided: #{opts[:hw_udid]}"}
System.find_executable("idevice_id") ->
case System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do
{out, 0} ->
udids =
out
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
case udids do
[] -> {:error, "no USB device detected (idevice_id -l returned empty)"}
[_one] -> {:ok, "USB device connected"}
many -> {:ok, "#{length(many)} USB devices connected"}
end
_ ->
{:error, "idevice_id failed — is the device trusted?"}
end
true ->
{:error,
"no hw_udid given and idevice_id not installed " <>
"(brew install libimobiledevice)"}
end
end
defp check_hardware_android(opts) do
serial = opts[:adb_serial]
cond do
is_nil(System.find_executable("adb")) ->
{:error, "adb not found (install Android platform-tools)"}
not is_binary(serial) ->
# Try `adb devices` to see if any device is reachable.
case System.cmd("adb", ["devices"], stderr_to_stdout: true) do
{out, 0} ->
devices =
out
|> String.split("\n")
|> Enum.drop(1)
|> Enum.flat_map(fn line ->
case String.split(line) do
[s, "device" | _] -> [s]
_ -> []
end
end)
case devices do
[] -> {:error, "no Android device detected (adb devices returned empty)"}
[single] -> {:ok, "device connected: #{single}"}
many -> {:ok, "#{length(many)} devices connected"}
end
_ ->
{:error, "adb devices failed"}
end
true ->
case System.cmd("adb", ["-s", serial, "get-state"], stderr_to_stdout: true) do
{out, 0} ->
state = String.trim(out)
if state == "device",
do: {:ok, "adb device #{serial} (#{state})"},
else: {:error, "adb device #{serial} state: #{state}"}
{out, _} ->
{:error, "adb get-state failed: #{String.trim(out)}"}
end
end
end
# ── App-installed check (platform-dispatched) ─────────────────────────
@doc false
@spec check_app_installed(:ios | :android, keyword()) ::
{:ok, String.t()} | {:error, String.t()}
def check_app_installed(platform, opts) do
case platform do
:ios -> check_app_installed_ios(opts)
:android -> check_app_installed_android(opts)
_ -> {:error, "unknown platform: #{inspect(platform)}"}
end
end
@doc false
@spec check_app_installed(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_app_installed(opts), do: check_app_installed(:ios, opts)
defp check_app_installed_ios(opts) do
bundle = opts[:bundle_id]
device = opts[:device_id]
cond do
not is_binary(bundle) ->
{:error, "bundle_id not configured"}
not is_binary(device) ->
# Without a device id, we can't query devicectl. Treat as informational.
{:ok, "skipped (no device_id provided to verify)"}
is_nil(System.find_executable("xcrun")) ->
{:ok, "skipped (xcrun unavailable)"}
true ->
# devicectl prints a noisy "No provider was found" provisioning
# warning to stderr and exits non-zero even when the query
# succeeded. Don't trust the exit code — search the combined
# output for the bundle id and treat that as authoritative.
{out, _exit} =
System.cmd(
"xcrun",
[
"devicectl",
"device",
"info",
"apps",
"--device",
device,
"--bundle-identifier",
bundle
],
stderr_to_stdout: true
)
cond do
String.contains?(out, bundle) ->
{:ok, "#{bundle} found on device"}
String.contains?(out, "ContainerLookupError") or
String.contains?(out, "not installed") ->
{:error, "#{bundle} not installed — run `mix mob.deploy --native`"}
true ->
# Couldn't verify via devicectl — but the other checks (BEAM
# reachability, NIF exports) will catch real "app not installed"
# cases anyway. Don't fail the run on devicectl noise.
{:ok, "couldn't verify via devicectl (BEAM reachability is authoritative)"}
end
end
end
defp check_app_installed_android(opts) do
bundle = opts[:bundle_id]
serial = opts[:adb_serial]
cond do
not is_binary(bundle) ->
{:error, "bundle_id not configured"}
is_nil(System.find_executable("adb")) ->
{:ok, "skipped (adb unavailable)"}
not is_binary(serial) ->
{:ok, "skipped (no adb_serial provided to verify)"}
true ->
case System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", bundle],
stderr_to_stdout: true
) do
{out, 0} ->
# `pm list packages com.example.foo` prints "package:com.example.foo"
# if installed; empty if not.
if String.contains?(out, "package:#{bundle}") do
{:ok, "#{bundle} installed on device"}
else
{:error, "#{bundle} not installed — run `mix mob.deploy --native`"}
end
{out, _} ->
{:error, "adb pm list failed: #{String.trim(out)}"}
end
end
end
@doc false
@spec check_beam_reachable(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_beam_reachable(opts) do
node = opts[:node]
host = opts[:host] || derive_host(node)
cond do
not is_atom(node) or node == nil ->
{:error, "no node provided"}
not is_binary(host) ->
{:error, "could not derive host from node #{inspect(node)}"}
Probe.tcp_open?(host, 4369, 1500) ->
{:ok, "EPMD reachable at #{host}:4369"}
true ->
{:error, "EPMD not reachable at #{host}:4369 — phone offline or BEAM dead"}
end
end
@doc false
@spec check_rpc_responsive(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_rpc_responsive(opts) do
node = opts[:node]
cookie = opts[:cookie]
cond do
not is_atom(node) or node == nil ->
{:error, "no node provided"}
true ->
if is_atom(cookie), do: Node.set_cookie(node, cookie)
case Node.connect(node) do
true ->
if Probe.rpc_responsive?(node, 2_000) do
{:ok, "RPC ping returned in <2 s"}
else
{:error, "RPC ping timed out (BEAM may be suspended)"}
end
false ->
{:error, "Node.connect/1 returned false — wrong cookie or dist down"}
:ignored ->
{:error, "Node.connect/1 returned :ignored — local node not started"}
end
end
end
@doc false
@spec check_nif_version(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_nif_version(opts) do
check_nif_export(opts, :battery_level, "battery_level/0")
end
@doc false
@spec check_keep_alive_nif(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_keep_alive_nif(opts) do
check_nif_export(opts, :background_keep_alive, "background_keep_alive/0")
end
defp check_nif_export(opts, fun_name, label) do
node = opts[:node]
case :rpc.call(node, :mob_nif, :module_info, [:exports], 2_000) do
list when is_list(list) ->
if Enum.any?(list, fn {f, _arity} -> f == fun_name end) do
{:ok, "#{label} exported"}
else
{:error,
"#{label} not exported on device — installed app is older than mob_dev expects"}
end
{:badrpc, reason} ->
{:error, "could not query exports: #{inspect(reason)}"}
other ->
{:error, "unexpected exports result: #{inspect(other)}"}
end
end
defp derive_host(node), do: MobDev.NodeUtil.host_from_node(node)
end