Packages
mob_dev
0.6.10
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/deployer.ex
defmodule MobDev.Deployer do
@moduledoc """
Pushes compiled BEAM files from `_build/dev/lib/*/ebin/` to connected devices.
Does NOT rebuild APKs or recompile native code — that's `deploy.sh` (first-time setup).
Use this for day-to-day code iteration: edit Elixir → `mix mob.deploy` → code running.
## Transport selection
**Erlang dist (preferred)**: when a device node is already reachable via Erlang
distribution, BEAMs are hot-loaded via RPC. No restart needed — modules are
loaded in place exactly like `nl/1` in IEx.
**adb push / cp (fallback)**: when no dist connection exists (first deploy, app not
running), falls back to the traditional push-then-restart path.
## Platform behaviour
**Android**: pushes via `adb push` (requires `adb root`, i.e. emulator or debug build),
or falls back to `adb push` → `/data/local/tmp/` → `run-as tar xf` for real devices.
**iOS simulator**: copies files locally into `/tmp/otp-ios-sim/beamhello/` (no network
hop — the simulator shares the Mac filesystem).
"""
alias MobDev.Discovery.{Android, IOS}
alias MobDev.{Device, HotPush, Tunnel}
@cookie :mob_secret
@android_activity ".MainActivity"
defp app_name, do: Mix.Project.config()[:app] |> to_string()
defp bundle_id, do: MobDev.Config.bundle_id()
defp android_package, do: bundle_id()
defp android_app_data, do: "/data/data/#{android_package()}/files"
defp android_beams_dir, do: "#{android_app_data()}/otp/#{app_name()}"
defp ios_bundle_id, do: bundle_id()
defp ios_beams_dir do
# The simulator's OTP_ROOT is resolved by `MobDev.Paths.sim_runtime_dir/1`.
# New projects: ~/.mob/runtime/ios-sim. Legacy projects (build.sh predates
# MOB_SIM_RUNTIME_DIR support): /tmp/otp-ios-sim. Either way, if that
# directory exists deploy beams there so the running BEAM picks them up
# immediately. Fall back to the cache dir on a fresh machine that hasn't
# done its first --native build yet.
runtime_dir = MobDev.Paths.sim_runtime_dir()
runtime_path = Path.join(runtime_dir, app_name())
cache_path = Path.join(MobDev.OtpDownloader.ios_sim_otp_dir(), app_name())
if File.dir?(runtime_dir), do: runtime_path, else: cache_path
end
@doc """
Discovers devices, pushes BEAMs, and optionally restarts apps.
Returns `{deployed, failed, skipped}` lists of `%Device{}`.
`skipped` is the deploy-isn't-applicable case — e.g. the app
isn't installed on a device because only the other platform was
built. Distinct from `failed` (real error during push).
"""
@spec deploy_all(keyword()) :: {[Device.t()], [Device.t()], [Device.t()]}
def deploy_all(opts \\ []) do
restart = Keyword.get(opts, :restart, true)
platforms = Keyword.get(opts, :platforms, [:android, :ios])
force_fs = Keyword.get(opts, :force_fs, false)
device_id = Keyword.get(opts, :device, nil)
ios_device_id = Keyword.get(opts, :ios_device, nil)
beam_flags = Keyword.get(opts, :beam_flags, nil)
beam_dirs = collect_beam_dirs()
android =
if :android in platforms,
do:
Android.list_devices()
|> Enum.reject(&(&1.status == :unauthorized))
|> filter_by_device_id(device_id),
else: []
ios =
if :ios in platforms,
do: IOS.list_devices() |> filter_by_device_id(ios_device_id || device_id),
else: []
all = android ++ ios
if all == [] do
IO.puts(" #{color(:yellow)}No devices found.#{color(:reset)}")
{[], [], []}
else
IO.puts(" Pushing #{count_beams(beam_dirs)} BEAM file(s) to #{length(all)} device(s)...")
# Try Erlang dist first — hot-loads modules with no restart. We set up
# tunnels and attempt Node.connect for each device; those that respond
# get BEAMs via RPC, the rest fall back to adb/cp + restart.
# force_fs: true skips dist and always writes to the filesystem — required
# after a native build/install where the old BEAM process is dead.
dist_nodes = if force_fs, do: [], else: connect_dist(all)
# Manual overrides from `mix mob.deploy --dist-port N --node-suffix X`.
# When set, all targeted devices share the same port/suffix (the user
# is being explicit about a single device they care about). The
# auto-allocated per-device values (one port per index, suffix per
# serial/UDID) only apply when these are nil.
dist_port_override = Keyword.get(opts, :dist_port)
node_suffix_override = Keyword.get(opts, :node_suffix)
results =
all
|> Enum.map(fn device ->
IO.write(" #{device.name || device.serial} → pushing...")
# Serial-derived so the port a device is deployed to listen on matches
# what `mix mob.connect` later forwards to (same crc32(serial) base).
dist_port = dist_port_override || Tunnel.serial_base_port(device.serial)
node = Device.node_name(device)
{method, result} =
if node in dist_nodes do
{:dist, push_via_dist(node, device)}
else
fallback =
case device.platform do
:android ->
deploy_android(device, beam_dirs,
restart: restart,
dist_port: dist_port,
node_suffix: node_suffix_override,
beam_flags: beam_flags
)
:ios ->
deploy_ios(device, beam_dirs,
restart: restart,
dist_port: dist_port,
node_suffix: node_suffix_override,
beam_flags: beam_flags
)
end
{:adb, fallback}
end
case result do
{:ok, d} ->
suffix = if method == :dist, do: " (dist, no restart)", else: ""
IO.puts(" #{color(:green)}✓#{suffix}#{color(:reset)}")
{:ok, d}
{:skipped, reason} ->
# Yellow dash, not a red x — this device wasn't a target.
IO.puts(
" #{color(:yellow)}—#{color(:reset)} #{color(:faint)}#{reason}#{color(:reset)}"
)
{:skipped, %{device | status: :skipped, error: reason}}
{:error, reason} ->
IO.puts(" #{color(:red)}✗#{color(:reset)}")
IO.puts(" #{color(:red)}#{reason}#{color(:reset)}")
{:error, %{device | status: :error, error: reason}}
end
end)
categorize_results(results)
end
end
@doc """
Bucket a per-device results list into `{deployed, failed, skipped}`.
Three outcomes:
* `:ok` — push succeeded → `deployed`
* `:skipped` — device wasn't a target (e.g. app not installed,
typical when only one platform was built) → `skipped`
* `:error` — push attempted and failed for a real reason → `failed`
Public so the categorization invariant (skipped never crosses into
failed; an unknown outcome isn't silently dropped) can be tested
independent of the hardware-dependent push pipeline.
"""
@spec categorize_results([{:ok | :skipped | :error, Device.t()}]) ::
{[Device.t()], [Device.t()], [Device.t()]}
def categorize_results(results) do
deployed = for {:ok, d} <- results, do: d
failed = for {:error, d} <- results, do: d
skipped = for {:skipped, d} <- results, do: d
{deployed, failed, skipped}
end
@doc """
True when the `adb shell pm list packages <pkg>` output indicates
`<pkg>` is installed on the device.
The check is a substring match for `package:<pkg>` because adb's
output is one `package:<name>` line per matching package — empty
output means "no match" (not "package called empty").
Public so the rule can be regression-tested without an emulator.
"""
@spec android_package_installed?(String.t(), String.t()) :: boolean()
def android_package_installed?(pm_output, package_name) when is_binary(pm_output) do
String.contains?(pm_output, "package:#{package_name}")
end
# ── Device filtering ─────────────────────────────────────────────────────────
defp filter_by_device_id(devices, nil), do: devices
defp filter_by_device_id(devices, id) do
case Enum.filter(devices, &Device.match_id?(&1, id)) do
[] ->
IO.puts(" #{color(:red)}No device matched \"#{id}\".#{color(:reset)}")
IO.puts(
" Run #{color(:cyan)}mix mob.devices#{color(:reset)} to see available device IDs."
)
[]
matched ->
matched
end
end
# ── Android ─────────────────────────────────────────────────────────────────
defp deploy_android(%Device{serial: serial} = device, beam_dirs, opts) do
restart = Keyword.get(opts, :restart, true)
dist_port = Keyword.get(opts, :dist_port, 9100)
node_suffix = Keyword.get(opts, :node_suffix)
beam_flags = Keyword.get(opts, :beam_flags, nil)
pkg = android_package()
{pm_out, _} =
System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", pkg],
stderr_to_stdout: true
)
if not android_package_installed?(pm_out, pkg) do
# NOT a failure — this device isn't a deploy target for this app.
# Returning `:skipped` lets the top-level report distinguish
# "device didn't have the app installed" (a normal multi-device
# situation when only one platform was built) from real push
# failures.
{:skipped,
"#{pkg} not installed on #{device.name || serial} (ABI mismatch or app not built for this platform)"}
else
case ensure_erts_on_device(serial, pkg) do
:ok ->
case push_beams_android(serial, beam_dirs) do
:ok ->
sync_elixir_stdlib_android(serial)
write_beam_flags_android(serial, beam_flags)
setup_exqlite_android(serial)
setup_app_priv_android(serial)
if restart,
do: restart_android(serial, dist_port: dist_port, node_suffix: node_suffix)
{:ok, device}
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
end
# Verify the OTP runtime (erts-X.Y/bin/erl_child_setup) is present on
# the device. Without this, the BEAM can't start — symlinks fail with
# ENOENT, the app crashes immediately. This typically happens when the
# device wasn't connected during a previous `mix mob.deploy --native`.
#
# Returns :ok if ERTS is present, {:error, message} with a helpful hint
# if missing.
defp ensure_erts_on_device(serial, pkg) do
# The wildcard must be expanded *inside* the run-as sandbox — `run-as`
# itself does not invoke a shell, and the outer adb-shell shell can't
# see /data/data/<pkg>/, so a literal "erts-*" gets passed to ls if we
# don't wrap with `sh -c` here.
cmd =
"run-as #{pkg} sh -c 'ls /data/data/#{pkg}/files/otp/erts-*/bin/erl_child_setup' 2>&1"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, out} ->
if String.contains?(out, "No such file") or String.contains?(out, "not found") do
{:error, erts_missing_message(serial, pkg)}
else
:ok
end
_ ->
# adb shell failed entirely — let the deploy proceed and fail later
# if needed; this check is best-effort.
:ok
end
end
defp erts_missing_message(serial, pkg) do
"""
OTP runtime missing on device #{serial}.
The app is installed, but /data/data/#{pkg}/files/otp/erts-*/bin/ is
empty. Without ERTS the BEAM can't start (you'll see "symlink
erl_child_setup failed: No such file or directory" in logcat).
This usually means the device wasn't connected during a previous
`mix mob.deploy --native`. Provision it now:
mix mob.deploy --native --device #{serial}
That rebuilds the APK and pushes the right OTP for this device's ABI.
Subsequent `mix mob.deploy` runs (without --native) will work normally.
"""
end
# If the Elixir stdlib on the device was installed by a different Elixir version
# than the host (e.g. after `asdf` upgrade), regex literals and other stdlib
# internals will be incompatible. Detect the mismatch and push updated BEAMs.
defp sync_elixir_stdlib_android(serial) do
host_vsn = System.version()
pkg = android_package()
app_data = android_app_data()
elixir_app = "#{app_data}/otp/lib/elixir/ebin/elixir.app"
device_vsn =
case run_adb(["-s", serial, "shell", "run-as #{pkg} cat #{elixir_app}"]) do
{:ok, content} -> MobDev.AppFile.vsn_from_content(content)
_ -> nil
end
if device_vsn != host_vsn do
Mix.shell().info([
:yellow,
"* Elixir version mismatch (device: #{device_vsn || "unknown"}, host: #{host_vsn}) — syncing stdlib...",
:reset
])
elixir_lib = :code.lib_dir(:elixir) |> to_string() |> Path.dirname()
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} when is_binary(out) ->
if out =~ "restarting" or out =~ "already running as root" do
:timer.sleep(600)
true
else
false
end
_ ->
false
end
if rooted? do
Enum.each([:elixir, :logger, :eex], fn app ->
src = Path.join(elixir_lib, "#{app}/ebin")
dst = "#{app_data}/otp/lib/#{app}/ebin"
if File.dir?(src) do
run_adb(["-s", serial, "shell", "mkdir -p #{dst}"])
run_adb(["-s", serial, "push", "#{src}/.", "#{dst}/"])
end
end)
else
sync_elixir_stdlib_android_runas(serial, pkg, app_data, elixir_lib)
end
Mix.shell().info([:green, "* Elixir stdlib synced to #{host_vsn}", :reset])
end
end
# Non-rooted path: stage elixir/logger/eex ebin into a tar on /data/local/tmp,
# then extract into the app sandbox via `run-as`. Files created by run-as are
# owned by the app user so they can be overwritten on the next sync.
defp sync_elixir_stdlib_android_runas(serial, pkg, app_data, elixir_lib) do
stage_local = Path.join(System.tmp_dir!(), "mob_elixir_#{serial}.tar")
stage_device = "/data/local/tmp/mob_elixir.tar"
try do
tmp = Path.join(System.tmp_dir!(), "mob_elixir_stage_#{serial}")
File.rm_rf!(tmp)
for app <- [:elixir, :logger, :eex] do
src = Path.join(elixir_lib, "#{app}/ebin")
if File.dir?(src) do
dst = Path.join(tmp, "#{app}/ebin")
File.mkdir_p!(dst)
System.cmd("cp", ["-r", "#{src}/.", dst], stderr_to_stdout: true)
end
end
System.cmd("tar", ["cf", stage_local, "-C", tmp, "."],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
)
run_adb(["-s", serial, "push", stage_local, stage_device])
# Extract relative to otp/lib/ so elixir/ebin, logger/ebin, eex/ebin land correctly.
cmd =
"run-as #{pkg} tar xf #{stage_device} -C #{app_data}/otp/lib 2>/dev/null; true"
run_adb(["-s", serial, "shell", cmd])
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
after
File.rm(stage_local)
File.rm_rf(Path.join(System.tmp_dir!(), "mob_elixir_stage_#{serial}"))
end
end
defp write_beam_flags_android(_serial, nil), do: :ok
defp write_beam_flags_android(serial, flags) do
beams_dir = android_beams_dir()
tmp = Path.join(System.tmp_dir!(), "mob_beam_flags_#{serial}")
File.write!(tmp, flags)
case System.cmd(
"adb",
["-s", serial, "shell", "run-as", android_package(), "test", "-d", beams_dir],
stderr_to_stdout: true
) do
{_, 0} ->
System.cmd("adb", ["-s", serial, "push", tmp, "#{beams_dir}/mob_beam_flags"],
stderr_to_stdout: true
)
_ ->
:ok
end
File.rm(tmp)
:ok
end
# Ensure exqlite lives in $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv} so that
# the OTP boot-time lib scan registers a correct lib_dir for the application.
# Without this, code:lib_dir(:exqlite) returns {:error, :bad_name} and exqlite's
# NIF on_load callback (which calls :code.priv_dir(:exqlite)) fails.
# mob_beam.c creates the sqlite3_nif.so symlink in priv/ at runtime (it knows
# the APK-hash-dependent nativeLibraryDir; we don't at deploy time).
defp setup_exqlite_android(serial) do
with vsn when is_binary(vsn) <- exqlite_version(),
exqlite_ebin when exqlite_ebin != nil <-
Path.wildcard("_build/dev/lib/exqlite/ebin") |> List.first() do
app_data = android_app_data()
exqlite_lib = "#{app_data}/otp/lib/exqlite-#{vsn}"
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
pkg = android_package()
:timer.sleep(600)
run_adb(["-s", serial, "shell", "mkdir -p #{exqlite_lib}/ebin #{exqlite_lib}/priv"])
run_adb(["-s", serial, "push", "#{Path.expand(exqlite_ebin)}/.", "#{exqlite_lib}/ebin/"])
# Read label from cache/ (has full s0:cXXX,cYYY MCS categories on Android 15),
# not files/ which carries a bare s0 label.
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{pkg}/cache) #{app_data}/otp/lib/exqlite-#{vsn}"
])
create_exqlite_nif_symlink(serial, exqlite_lib, :rooted)
else
push_exqlite_runas(serial, exqlite_ebin, exqlite_lib)
end
else
# exqlite not present or version unknown — skip silently
_ -> :ok
end
end
# Push the app's priv/ directory to {beams_dir}/priv/ on the device so that
# migration .exs files are available at runtime.
#
# WHY THIS IS NECESSARY
#
# Ecto.Migrator locates migration files via :code.priv_dir(app), which looks
# up the app's OTP lib directory ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps
# are deployed as flat .beam files in a -pa directory — there is no versioned
# lib structure — so :code.priv_dir/1 returns {error, bad_name}. When that
# happens Ecto.Migrator.run silently finds zero migrations and logs "Migrations
# already up" without creating any tables.
#
# The fix has two parts:
# 1. This function pushes priv/ to {beams_dir}/priv/ on the device.
# 2. mob_beam.c sets MOB_BEAMS_DIR=beams_dir before erl_start so app code
# can call Ecto.Migrator.run(repo, beams_dir <> "/priv/repo/migrations", ...)
# with an explicit path instead of relying on :code.priv_dir/1.
#
# PERMISSION TRAP: chmod -R 755 is not optional.
#
# `mkdir -p` executed via `adb root` shell creates directories owned by
# system:system with mode drwxrwx--x (owner=rwx, group=rwx, other=--x).
# The BEAM process runs as the app user (u0_a0), which is "other" relative to
# system:system, so it gets only --x (traverse, no read). Path.wildcard calls
# opendir(3) on the directory, which requires read permission (r bit). Without
# it, wildcard returns [] even though the .exs file is right there — and Ecto
# again logs "Migrations already up". chmod -R 755 gives world-readable
# directories (r-x for other) while keeping files at their pushed permissions.
defp setup_app_priv_android(serial) do
local_priv = Path.join(File.cwd!(), "priv")
if File.dir?(local_priv) do
device_priv = "#{android_beams_dir()}/priv"
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
:timer.sleep(600)
run_adb(["-s", serial, "shell", "mkdir -p #{device_priv}"])
run_adb(["-s", serial, "push", "#{Path.expand(local_priv)}/.", "#{device_priv}/"])
# Make directories world-readable. mkdir as root creates them system:system
# drwxrwx--x; the app process (other) gets only --x → Path.wildcard returns
# [] → migrations silently skipped. See comment above for the full story.
run_adb(["-s", serial, "shell", "chmod -R 755 #{device_priv}"])
# Fix SELinux MCS categories so the app can actually open the files.
# Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15).
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_beams_dir()}"
])
else
push_priv_android_runas(serial, local_priv, device_priv)
end
end
:ok
end
# Non-rooted path: stage priv/ into a tar on /data/local/tmp (world-writable),
# then extract into the app sandbox via `run-as`. Files created by run-as are
# owned by the app user (u0_a0) so no chmod is needed — the app can read its
# own files without any extra permission fixup.
defp push_priv_android_runas(serial, local_priv, device_priv) do
stage_local = Path.join(System.tmp_dir!(), "mob_priv_#{serial}.tar")
stage_device = "/data/local/tmp/mob_priv.tar"
try do
# Tar with priv/ as the top-level entry; extract relative to beams_dir so
# the result lands at {beams_dir}/priv/repo/migrations/... etc.
case System.cmd(
"tar",
["cf", stage_local, "-C", Path.dirname(local_priv), Path.basename(local_priv)],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "tar create failed: #{out}"})
end
case run_adb(["-s", serial, "push", stage_local, stage_device]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "adb push failed: #{r}"})
end
run_adb(["-s", serial, "shell", "run-as #{android_package()} mkdir -p #{device_priv}"])
cmd =
"run-as #{android_package()} tar xf #{stage_device} -C #{android_beams_dir()} 2>/dev/null; true"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "run-as tar failed: #{r}"})
end
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
catch
{:error, reason} ->
IO.puts(" (warning: priv push failed: #{reason})")
after
File.rm(stage_local)
end
end
defp push_exqlite_runas(serial, exqlite_ebin, exqlite_lib) do
stage_local = Path.join(System.tmp_dir!(), "mob_exqlite_#{serial}.tar")
stage_device = "/data/local/tmp/mob_exqlite.tar"
tmp = Path.join(System.tmp_dir!(), "mob_exqlite_stage_#{serial}")
try do
File.rm_rf!(tmp)
File.mkdir_p!(Path.join(tmp, "ebin"))
File.mkdir_p!(Path.join(tmp, "priv"))
System.cmd("cp", ["-r", "#{exqlite_ebin}/.", Path.join(tmp, "ebin")],
stderr_to_stdout: true
)
# Tar with ebin/ and priv/ as top-level entries; extract to exqlite_lib/.
case System.cmd("tar", ["cf", stage_local, "-C", tmp, "."],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "tar create failed: #{out}"})
end
case run_adb(["-s", serial, "push", stage_local, stage_device]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "adb push failed: #{r}"})
end
cmd =
"run-as #{android_package()} mkdir -p #{exqlite_lib}/ebin #{exqlite_lib}/priv && " <>
"run-as #{android_package()} tar xf #{stage_device} -C #{exqlite_lib}/ 2>/dev/null; true"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "run-as tar failed: #{r}"})
end
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
create_exqlite_nif_symlink(serial, exqlite_lib, :runas)
:ok
catch
{:error, reason} ->
IO.puts(" (warning: exqlite lib setup failed: #{reason})")
:ok
after
File.rm(stage_local)
File.rm_rf(tmp)
end
end
# Create sqlite3_nif.so symlink in the exqlite priv dir pointing at the APK's
# native lib. Uses `pm path` to locate the APK (its parent dir contains lib/arm64/).
# On non-rooted devices we use `run-as` since the priv dir is in the app sandbox.
defp create_exqlite_nif_symlink(serial, exqlite_lib, mode) do
case run_adb(["-s", serial, "shell", "pm path #{android_package()}"]) do
{:ok, path_out} ->
# pm path can return multiple lines (split APKs: base + config.*); they
# all live under the same install dir, so take the first.
apk_path =
path_out
|> String.split("\n", trim: true)
|> List.first("")
|> String.trim()
|> String.replace_prefix("package:", "")
apk_dir = Path.dirname(apk_path)
case resolve_sqlite_nif_target(serial, apk_dir) do
nil ->
IO.puts(
" (warning: libsqlite3_nif.so not found under #{apk_dir}/lib — " <>
"exqlite NIF symlink skipped)"
)
nif_target ->
nif_link = "#{exqlite_lib}/priv/sqlite3_nif.so"
cmd =
case mode do
:runas -> "run-as #{android_package()} ln -sf #{nif_target} #{nif_link}"
:rooted -> "ln -sf #{nif_target} #{nif_link}"
end
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, e} -> IO.puts(" (warning: exqlite NIF symlink failed: #{e})")
end
end
_ ->
IO.puts(" (warning: pm path failed — exqlite NIF symlink skipped)")
end
end
# The native lib lands under `lib/<abi>/` — `arm64-v8a` → "arm64",
# `armeabi-v7a` → "arm". Android extracts only the device's active ABI, so a
# glob matches exactly one file. Probe for it rather than assuming 64-bit, so
# 32-bit devices (older / low-end phones) get a real target instead of a
# dangling `lib/arm64` symlink (which left exqlite `:nif_not_loaded` and
# crashed boot). Returns the absolute path or nil.
@doc false
@spec __sqlite_nif_target__([String.t()]) :: String.t() | nil
def __sqlite_nif_target__(ls_lines) do
ls_lines
|> Enum.map(&String.trim/1)
|> Enum.find(&String.ends_with?(&1, "/libsqlite3_nif.so"))
end
defp resolve_sqlite_nif_target(serial, apk_dir) do
case run_adb(["-s", serial, "shell", "ls #{apk_dir}/lib/*/libsqlite3_nif.so 2>/dev/null"]) do
{:ok, out} -> __sqlite_nif_target__(String.split(out, "\n", trim: true))
_ -> nil
end
end
defp exqlite_version, do: MobDev.AppFile.dep_version(:exqlite)
defp push_beams_android(serial, beam_dirs) do
# Try adb root first (works on emulators and eng builds).
# Check the output text — non-rooted devices return exit 0 with
# "cannot run as root in production builds".
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
:timer.sleep(600)
run_adb(["-s", serial, "shell", "mkdir -p #{android_beams_dir()}"])
result =
Enum.reduce_while(beam_dirs, :ok, fn dir, _ ->
case run_adb(["-s", serial, "push", "#{Path.expand(dir)}/.", "#{android_beams_dir()}/"]) do
{:ok, _} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, "push failed: #{reason}"}}
end
end)
# Fix SELinux MCS categories on pushed files. adb push (as root) labels
# files with root's categories; restorecon only fixes the type, not MCS.
# Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15).
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_app_data()}/otp"
])
result
else
# Fall back to run-as tar (non-rooted physical devices).
push_beams_android_runas(serial, beam_dirs)
end
end
defp push_beams_android_runas(serial, beam_dirs) do
stage_local = System.tmp_dir!() |> Path.join("mob_beams_#{serial}.tar")
stage_device = "/data/local/tmp/mob_beams.tar"
try do
tmp = Path.join(System.tmp_dir!(), "mob_beam_stage_#{serial}")
File.rm_rf!(tmp)
File.mkdir_p!(tmp)
Enum.each(beam_dirs, fn dir ->
System.cmd("cp", ["-r", "#{dir}/.", tmp], stderr_to_stdout: true)
end)
# Archive from inside tmp so there is no top-level wrapper directory.
# BusyBox/Toybox tar (Android ≤11) does not support --strip-components, so
# we avoid needing it by using `tar cf ... -C tmp .`.
# COPYFILE_DISABLE=1 prevents macOS from adding ._<file> AppleDouble
# sidecars into the archive.
case System.cmd("tar", ["cf", stage_local, "-C", tmp, "."],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "tar create failed: #{out}"})
end
case run_adb(["-s", serial, "push", stage_local, stage_device]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "adb push failed: #{r}"})
end
run_adb([
"-s",
serial,
"shell",
"run-as #{android_package()} mkdir -p #{android_beams_dir()}"
])
# Redirect stderr and always exit 0: Android's Toybox tar cannot chown to
# macOS UID 501 and exits 1, but the files are extracted correctly.
cmd =
"run-as #{android_package()} tar xf #{stage_device} -C #{android_beams_dir()}/ 2>/dev/null; true"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "run-as tar failed: #{r}"})
end
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
:ok
catch
{:error, reason} -> {:error, reason}
after
File.rm(stage_local)
end
end
defp restart_android(serial, opts) do
dist_port = Keyword.get(opts, :dist_port, 9100)
node_suffix = Keyword.get(opts, :node_suffix) || Android.device_node_suffix(serial)
run_adb(["-s", serial, "shell", "am", "force-stop", android_package()])
# Heal SELinux MCS category mismatch before start — APK reinstall changes
# the app's category but leaves OTP files with stale labels.
# Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15).
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_app_data()}/otp"
])
:timer.sleep(300)
run_adb([
"-s",
serial,
"shell",
"am",
"start",
"-n",
"#{android_package()}/#{@android_activity}",
"--ei",
"mob_dist_port",
to_string(dist_port),
"--es",
"mob_node_suffix",
node_suffix
])
:ok
end
# ── iOS ─────────────────────────────────────────────────────────────────────
defp deploy_ios(%Device{type: :physical} = device, beam_dirs, opts) do
deploy_ios_physical(device, beam_dirs, opts)
end
defp deploy_ios(device, beam_dirs, opts) do
deploy_ios_simulator(device, beam_dirs, opts)
end
defp deploy_ios_simulator(%Device{serial: udid} = device, beam_dirs, opts) do
restart = Keyword.get(opts, :restart, true)
dist_port = Keyword.get(opts, :dist_port, 9100)
beam_flags = Keyword.get(opts, :beam_flags, nil)
try do
File.mkdir_p!(ios_beams_dir())
# Use rsync rather than cp -r for two reasons that hit Nix users hard:
#
# 1. macOS BSD `cp` preserves source mode in practice (despite what the
# man page implies). Sources in /nix/store are mode 444, so cp leaves
# 444 files in our managed runtime dir; the next deploy then trips on
# `cp: cannot create regular file ... Permission denied` when trying
# to overwrite them.
#
# 2. cp -r unconditionally overwrites every file, even when nothing has
# changed. rsync's mtime+size check skips identical files, so a
# no-op deploy after a previous successful one is genuinely a no-op.
#
# `--no-perms` keeps existing destination permissions untouched and uses
# the user's umask for newly-created files (so we don't propagate Nix's
# 444 mode). rsync's atomic-rename writer can also overwrite a 444
# destination cleanly without needing a chmod first.
Enum.each(beam_dirs, fn dir ->
abs_dir = Path.expand(dir)
case System.cmd(
"rsync",
["-a", "--no-perms", "#{abs_dir}/", "#{ios_beams_dir()}/"],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "rsync failed: #{out}"})
end
end)
# Push priv/ alongside the BEAMs so migrations and other priv assets are
# available at runtime. On iOS, beams_dir = $RUNTIME_DIR/APP_NAME and
# MOB_DATA_DIR = the app's Documents directory — these are two different
# paths, so we can't derive beams_dir from MOB_DATA_DIR. mob_beam.m sets
# MOB_BEAMS_DIR=beams_dir explicitly so app code always knows where to look.
local_priv = Path.join(File.cwd!(), "priv")
if File.dir?(local_priv) do
priv_dest = Path.join(ios_beams_dir(), "priv")
File.mkdir_p!(priv_dest)
case System.cmd(
"rsync",
["-a", "--no-perms", "#{Path.expand(local_priv)}/", "#{priv_dest}/"],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> IO.puts(" (warning: iOS priv push failed: #{out})")
end
end
if beam_flags do
File.write!(Path.join(ios_beams_dir(), "mob_beam_flags"), beam_flags)
end
if restart do
IOS.terminate_app(udid, ios_bundle_id())
:timer.sleep(300)
# node_suffix nil → IOS.launch_app omits SIMCTL_CHILD_MOB_NODE_SUFFIX
# → mob_beam.m auto-derives from SIMULATOR_UDID. Pass explicit when
# `mix mob.deploy --node-suffix ...` was used.
node_suffix = Keyword.get(opts, :node_suffix)
IOS.launch_app(udid, ios_bundle_id(), dist_port: dist_port, node_suffix: node_suffix)
end
{:ok, device}
catch
{:error, reason} -> {:error, reason}
end
end
# Physical iOS deploy: push BEAMs into the app's Documents container via
# `xcrun devicectl`. mob_beam.m (MOB_BUNDLE_OTP build) checks
# Documents/otp/<app>/ at startup and prefers it over the read-only in-bundle
# copy, enabling fast deploys without a full Xcode rebuild.
#
# The merged staging dir is named <app> so that devicectl's directory-copy
# semantics land the files at Documents/otp/<app>/ on device.
defp deploy_ios_physical(%Device{serial: udid} = device, beam_dirs, opts) do
restart = Keyword.get(opts, :restart, true)
beam_flags = Keyword.get(opts, :beam_flags, nil)
bundle = ios_bundle_id()
app = app_name()
# When discovered via WiFi-only EPMD scan the serial is the IP address, which
# xcrun devicectl does not accept as a --device argument. Resolve to a hardware
# UDID before proceeding.
udid = resolve_ios_udid_if_ip(udid)
if Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) do
throw(
{:error,
"device only reachable via WiFi (#{udid}) — use `mix mob.push` for BEAM-only updates, or connect via USB for a native deploy"}
)
end
# Stage all BEAMs (and priv/) into a temp dir named <app>.
staging_parent =
Path.join(System.tmp_dir!(), "mob_ios_deploy_#{:erlang.unique_integer([:positive])}")
staging_dir = Path.join(staging_parent, app)
File.mkdir_p!(staging_dir)
try do
Enum.each(beam_dirs, fn dir ->
case System.cmd("cp", ["-r", "#{Path.expand(dir)}/.", staging_dir],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "cp failed: #{out}"})
end
end)
local_priv = Path.join(File.cwd!(), "priv")
if File.dir?(local_priv) do
priv_dest = Path.join(staging_dir, "priv")
File.mkdir_p!(priv_dest)
case System.cmd("cp", ["-r", "#{Path.expand(local_priv)}/.", priv_dest],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> IO.puts(" (warning: priv copy failed: #{out})")
end
end
if beam_flags do
File.write!(Path.join(staging_dir, "mob_beam_flags"), beam_flags)
end
# devicectl copies the contents of --source into --destination.
# To land BEAMs at Documents/otp/<app>/, the destination must include
# the app subdirectory explicitly (staging_dir naming alone is not enough).
case System.cmd(
"xcrun",
[
"devicectl",
"device",
"copy",
"to",
"--device",
udid,
"--domain-type",
"appDataContainer",
"--domain-identifier",
bundle,
"--source",
staging_dir,
"--destination",
"Documents/otp/#{app}"
],
stderr_to_stdout: true
) do
{_, 0} ->
:ok
{out, _} ->
reason =
if String.contains?(out, "ContainerLookupErrorDomain") do
"""
App '#{bundle}' is not installed on this device.
To fix this, you need to build and install the app on the device first.
The easiest way is to open the ios/ directory in Xcode and run on device:
open ios/*.xcodeproj (or ios/*.xcworkspace)
Then select your device in Xcode and press Run (⌘R).
Alternatively, if you have another app with a different bundle ID already
installed on the device, update bundle_id in mob.exs to match it:
config :mob_dev, bundle_id: "com.yourcompany.yourapp"
"""
else
"devicectl copy failed: #{out}"
end
throw({:error, reason})
end
if restart, do: IOS.restart_app_physical(udid, bundle)
{:ok, device}
catch
{:error, reason} -> {:error, reason}
after
File.rm_rf!(staging_parent)
end
end
# ── iOS WiFi UDID resolution ──────────────────────────────────────────────────
# When a physical device was discovered only via LAN EPMD scan (no USB), its
# serial is the IP address. xcrun devicectl requires a hardware UDID or
# CoreDevice UUID for --device. This function resolves an IP to a UDID.
defp resolve_ios_udid_if_ip(udid) do
if Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) do
resolve_udid_from_ip(udid) || udid
else
udid
end
end
defp resolve_udid_from_ip(ip) do
# Strategy 1: idevice_id -n lists network-connected device UDIDs.
# If exactly one is found, it must be the WiFi-only device we're targeting.
# Strategy 2: xcrun devicectl list devices, subtract known USB devices.
from_idevice_id_network(ip) ||
from_devicectl_list(ip)
end
defp from_idevice_id_network(ip) do
with true <- not is_nil(System.find_executable("idevice_id")),
{out, 0} <- System.cmd("idevice_id", ["-n"], stderr_to_stdout: true),
udids <-
out |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")),
true <- udids != [] do
# With a single network device, return it immediately without querying its IP.
# With multiple, use ideviceinfo to find which UDID has the target IP.
case udids do
[single] ->
single
many ->
Enum.find_value(many, fn udid ->
case System.cmd("ideviceinfo", ["--network", "-u", udid, "-k", "IPAddress"],
stderr_to_stdout: true
) do
{ip_out, 0} ->
if String.trim(ip_out) == ip, do: udid, else: nil
_ ->
nil
end
end)
end
else
_ -> nil
end
end
defp from_devicectl_list(_ip) do
usb_udids =
if System.find_executable("idevice_id") do
case System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do
{out, 0} ->
out
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> MapSet.new()
_ ->
MapSet.new()
end
else
MapSet.new()
end
with {json, 0} <-
System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", "-"],
stderr_to_stdout: true
),
{:ok, data} <- Jason.decode(json),
devices <- get_in(data, ["result", "devices"]) || [],
wifi_only <-
Enum.reject(devices, fn d ->
hw_udid = get_in(d, ["hardwareProperties", "udid"]) || ""
MapSet.member?(usb_udids, hw_udid)
end),
[device] <- wifi_only,
udid when not is_nil(udid) <- get_in(device, ["hardwareProperties", "udid"]) do
udid
else
_ -> nil
end
end
# ── Dist push ────────────────────────────────────────────────────────────────
# Try to connect via Erlang dist to each discovered device. Returns a list of
# connected node atoms. Devices that don't respond are left for the adb fallback.
defp connect_dist(devices) do
ensure_local_dist()
Enum.flat_map(devices, fn device ->
node = Device.node_name(device)
Node.set_cookie(node, @cookie)
if Node.connect(node), do: [node], else: []
end)
rescue
_ -> []
end
defp ensure_local_dist do
unless Node.alive?() do
Node.start(:"mob_dev@127.0.0.1", :longnames)
Node.set_cookie(@cookie)
end
end
# Push all compiled BEAMs to a single dist-connected node, then trigger
# a re-render of the currently displayed screen so the user sees the changes
# immediately without a full restart.
#
# WHY THE RE-RENDER MESSAGE IS NECESSARY
#
# Erlang hot code loading (`code:load_binary`) replaces the module in the
# code server but does NOT cause running processes to re-execute. A
# Mob.Screen GenServer that is already mounted and displaying will continue
# to sit in its receive loop waiting for the next message. Until something
# sends it a message, `render/1` never runs again — so the user sees the
# old UI even though the new code is live in memory.
#
# The fix: immediately after the push, RPC-send `:__mob_hot_reload__` to the
# `:mob_screen` registered process on the device. Mob.Screen's handle_info
# catch-all receives it, delegates to the user module's handle_info (which
# ignores unknown messages), then calls do_render/2 using the now-current
# version of the screen module. The screen repaints with the new code, with
# no restart and no loss of GenServer state.
#
# This is why `mix mob.deploy` appeared to do nothing before this fix — the
# code WAS pushed correctly, the screen just had no trigger to repaint.
defp push_via_dist(node, device) do
{_pushed, failed} = HotPush.push_all([node])
if failed == [] do
# Best-effort: ignored if no screen is currently registered (nav edge
# cases, app in background, etc.).
:rpc.call(node, :erlang, :send, [:mob_screen, :__mob_hot_reload__])
{:ok, device}
else
mods = Enum.map_join(failed, ", ", fn {mod, _} -> inspect(mod) end)
{:error, "dist push failed for: #{mods}"}
end
end
# ── Helpers ──────────────────────────────────────────────────────────────────
defp collect_beam_dirs do
# Use the same runtime-dep filter as HotPush so we don't push dev-only
# tooling (mob_dev, credo, etc.) to the device filesystem.
app_dirs = HotPush.runtime_beam_dirs()
# EEx is part of the Elixir stdlib but not in _build/dev/lib/. Ecto depends
# on it, so include it in every push so it lands in the flat beams_dir
# (which is already on the -pa code path on both Android and iOS).
eex_ebin = Path.join(to_string(:code.lib_dir(:eex)), "ebin")
stdlib_dirs = if File.dir?(eex_ebin), do: [eex_ebin], else: []
# ssl is a required OTP app (thousand_island lists it as a dependency) but the
# iOS and Android OTP builds omit it. ssl is pure Erlang (no NIFs), so host
# BEAM files run identically on both targets. For HTTP-only Phoenix at loopback,
# ssl starts but no TLS sockets are opened.
ssl_ebin = Path.join(to_string(:code.lib_dir(:ssl)), "ebin")
ssl_dirs = if File.dir?(ssl_ebin), do: [ssl_ebin], else: []
# crypto is a required OTP app. Older device-side OTP builds (pre-2026-05)
# were configured `--without-ssl` to skip OpenSSL, so the device runtime
# had no real crypto and we shipped a deliberately-insecure shim
# (md5-only hash, no x25519, no AEAD) just to let `ensure_all_started`
# succeed for HTTP-only Phoenix at loopback.
#
# Newer tarballs ship a real crypto.so NIF + crypto.beam built against
# OpenSSL 3.x. When the host has a cached OTP runtime that already
# contains crypto.beam, we MUST NOT push the shim — its `crypto.beam`
# would land first in the on-device code path and shadow the real one,
# making `crypto:generate_key/2` undef even though the NIF is loaded.
shim_dirs =
if real_device_crypto_available?() do
[]
else
case generate_crypto_shim() do
{:ok, dir} -> [dir]
_ -> []
end
end
app_dirs ++ stdlib_dirs ++ ssl_dirs ++ shim_dirs
end
# Detects whether any cached device-side OTP runtime under ~/.mob/cache/
# already ships a real crypto.beam. If yes, the deployer must skip the
# shim — pushing the shim's crypto.beam shadows the real one in the
# on-device code path, making :crypto.generate_key/2 (and friends) undef
# despite the NIF being loaded.
@spec real_device_crypto_available?() :: boolean()
defp real_device_crypto_available? do
cache = Path.join([System.user_home!(), ".mob", "cache"])
Path.wildcard(Path.join([cache, "otp-*", "lib", "crypto-*", "ebin", "crypto.beam"]))
|> Enum.any?()
end
# Generates crypto.beam + crypto.app in a temp dir and returns {:ok, dir}.
# Returns {:error, reason} if erlc is not available.
#
# Used only as a fallback when the device-side OTP runtime was built
# `--without-ssl` (pre-2026-05 tarballs). Modern tarballs ship a real
# crypto.so NIF; in that case `real_device_crypto_available?/0` returns
# true and this shim is skipped.
@doc false
@spec generate_crypto_shim() :: {:ok, String.t()} | {:error, term()}
def generate_crypto_shim do
dir = Path.join(System.tmp_dir!(), "mob_crypto_shim")
File.mkdir_p!(dir)
src = Path.join(dir, "crypto.erl")
File.write!(src, """
-module(crypto).
-export([strong_rand_bytes/1, hash/2, mac/4, mac/3, supports/1, pbkdf2_hmac/5, exor/2]).
strong_rand_bytes(N) -> rand:bytes(N).
hash(_Type, Data) -> erlang:md5(Data).
%% HMAC-MD5 (ignores hash algorithm) — dev-only shim, no OpenSSL required.
mac(hmac, _Alg, Key, Data) -> hmac_md5(Key, Data);
mac(_Type, _SubType, _Key, _Data) -> <<>>.
mac(hmac, Key, Data) -> hmac_md5(Key, Data);
mac(_Type, _Key, _Data) -> <<>>.
supports(_Type) -> [].
%% PBKDF2-HMAC shim using HMAC-MD5 as PRF. Not cryptographically secure;
%% suitable only for local dev on-device where 127.0.0.1 is the only listener.
pbkdf2_hmac(_Hash, Password0, Salt0, Iterations, DerivedLen) ->
Password = iolist_to_binary(Password0),
Salt = iolist_to_binary(Salt0),
Blocks = (DerivedLen + 15) div 16,
Derived = iolist_to_binary([pbkdf2_block(Password, Salt, Iterations, I)
|| I <- lists:seq(1, Blocks)]),
binary:part(Derived, 0, DerivedLen).
pbkdf2_block(Password, Salt, Iterations, BlockNum) ->
U1 = hmac_md5(Password, <<Salt/binary, BlockNum:32/big>>),
pbkdf2_iterate(Password, U1, Iterations - 1, U1).
pbkdf2_iterate(_Password, _Prev, 0, Acc) -> Acc;
pbkdf2_iterate(Password, Prev, N, Acc) ->
U = hmac_md5(Password, Prev),
pbkdf2_iterate(Password, U, N - 1, xor_bins(Acc, U)).
hmac_md5(Key0, Data0) ->
Key = iolist_to_binary(Key0),
Data = iolist_to_binary(Data0),
BS = 64,
K = case byte_size(Key) > BS of
true -> erlang:md5(Key);
false -> Key
end,
Pad = binary:copy(<<0>>, BS - byte_size(K)),
KPad = <<K/binary, Pad/binary>>,
IKey = << <<(X bxor 16#36)>> || <<X>> <= KPad >>,
OKey = << <<(X bxor 16#5c)>> || <<X>> <= KPad >>,
erlang:md5(<<OKey/binary, (erlang:md5(<<IKey/binary, Data/binary>>))/binary>>).
xor_bins(A, B) ->
list_to_binary([X bxor Y || {X, Y} <- lists:zip(binary_to_list(A), binary_to_list(B))]).
exor(A, B) ->
xor_bins(iolist_to_binary(A), iolist_to_binary(B)).
""")
app =
"{application,crypto,[{modules,[crypto]},{applications,[kernel,stdlib]}," <>
"{description,\"Crypto shim for mobile (no OpenSSL; uses rand:bytes)\"}," <>
"{registered,[]},{vsn,\"5.6\"}]}."
File.write!(Path.join(dir, "crypto.app"), app)
case System.cmd("erlc", ["-o", dir, src], stderr_to_stdout: true) do
{_, 0} -> {:ok, dir}
{out, _} -> {:error, "crypto shim compile failed: #{out}"}
end
end
defp count_beams(beam_dirs) do
Enum.reduce(beam_dirs, 0, fn dir, acc ->
case File.ls(dir) do
{:ok, files} -> acc + Enum.count(files, &String.ends_with?(&1, ".beam"))
_ -> acc
end
end)
end
defp run_adb(args) do
case System.cmd("adb", args, stderr_to_stdout: true) do
{output, 0} -> {:ok, String.trim(output)}
{output, _} -> {:error, String.trim(output)}
end
end
defp color(:green), do: IO.ANSI.green()
defp color(:yellow), do: IO.ANSI.yellow()
defp color(:red), do: IO.ANSI.red()
defp color(:cyan), do: IO.ANSI.cyan()
defp color(:faint), do: IO.ANSI.faint()
defp color(:reset), do: IO.ANSI.reset()
end