Packages
mob_dev
0.6.21
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/mix/tasks/mob.battery_bench_android.ex
defmodule Mix.Tasks.Mob.BatteryBenchAndroid do
use Mix.Task
alias MobDev.Bench.{DeviceObserver, Logger, Preflight, Probe, Reconnector, Summary}
@shortdoc "Run a battery benchmark on an Android device"
@moduledoc """
Builds a benchmark APK, deploys it, and measures battery drain over time.
Run this from your Mob app project directory (the one containing `android/`,
`mob.exs`, and your Elixir source). It requires `bundle_id` to be set in
`mob.exs`:
config :mob_dev,
mob_dir: "/path/to/mob",
bundle_id: "com.example.myapp"
Reports mAh every 10 seconds and prints a summary at the end.
WiFi ADB is required for accurate measurements (USB cable charges the battery).
## Setup (one-time, while plugged in)
adb -s SERIAL tcpip 5555
adb connect PHONE_IP:5555
# then unplug and pass PHONE_IP:5555 as --device
## Recommended workflow
Same two-step pattern as iOS — push BEAM flags via `mix mob.deploy`, then
bench with `--no-build`. Lets you change tuning without a Gradle rebuild.
# 1. Push BEAM flags via mob.deploy (no APK rebuild — ~10 sec).
mix mob.deploy --beam-flags "" --android # tuned (Nerves)
mix mob.deploy --beam-flags "-S 4:4 -A 8" --android # untuned variant
# 2. Run the bench with --no-build.
mix mob.battery_bench_android --no-build --device 192.168.1.42:5555
See `README.md` for the full rationale and recovery procedure.
## Usage (with built-in Gradle build path)
mix mob.battery_bench_android
mix mob.battery_bench_android --no-beam
mix mob.battery_bench_android --preset nerves
mix mob.battery_bench_android --flags "-sbwt none -S 1:1"
mix mob.battery_bench_android --duration 3600 --device 192.168.1.42:5555
mix mob.battery_bench_android --no-build # re-run without rebuilding
## Options
* `--duration N` — benchmark duration in **seconds** (default: 1800 = 30 min)
* `--device SERIAL` — adb device serial or IP:port (auto-detected if omitted)
* `--no-beam` — baseline: build without starting the BEAM at all
* `--no-keep-alive` — skip the foreground-service background keep-alive call
* `--preset NAME` — named BEAM flag preset (Gradle-build path only)
* `--flags "..."` — arbitrary BEAM VM flags (Gradle-build path only)
* `--no-build` — skip APK build and install; run benchmark on current install
* `--log-path PATH` — override CSV log location (default: `_build/bench/run_android_<ts>.csv`)
* `--no-csv` — skip CSV logging
* `--skip-preflight` — bypass the preflight checks (adb/app/BEAM/RPC/NIF/keep-alive)
## What the presets do
* `untuned` — raw BEAM with no tuning flags (highest power use baseline)
* `sbwt` — only busy-wait disabled (`-sbwt none`)
* `nerves` — full Nerves set: single scheduler + busy-wait off + multi_time_warp
* (default) — same as `nerves` (production default)
## Understanding the results
The BEAM with Nerves-style tuning flags uses roughly the same power as an app
with no BEAM at all (~200 mAh/hr on a Moto G, 30-min run). The untuned BEAM
uses ~25% more power due to scheduler busy-waiting. For most apps the overhead
is in the noise; tune if you have stricter power budgets.
## Under the hood
`mix mob.battery_bench_android` orchestrates the following adb and Gradle commands:
# Build and install
./gradlew assembleDebug [-PextraCppFlags="-DNO_BEAM|..."]
adb install -r app/build/outputs/apk/debug/app-debug.apk
# Push BEAMs
adb push _build/dev/lib/*/ebin/*.beam /data/data/<pkg>/files/otp/<app>/
# Reset battery stats and launch
adb shell dumpsys batterystats --reset
adb shell am start -n <pkg>/.MainActivity
# Turn screen off
adb shell input keyevent 26 # KEYCODE_POWER
# Poll battery every 10s
adb shell dumpsys battery # reads "Charge counter: <µAh>"
# Stop app and collect final reading
adb shell am force-stop <pkg>
adb shell dumpsys battery
BEAM tuning flags are injected as C preprocessor defines (`-DBEAM_UNTUNED`,
`-DBEAM_FULL_NERVES`, etc.) or via a generated `mob_beam_flags.h` header, so
each preset compiles a different variant of the BEAM startup C code.
"""
@switches [
duration: :integer,
device: :string,
no_beam: :boolean,
no_keep_alive: :boolean,
preset: :string,
flags: :string,
no_build: :boolean,
dry_run: :boolean,
log_path: :string,
no_csv: :boolean,
skip_preflight: :boolean
]
@android_activity ".MainActivity"
@impl Mix.Task
def run(args) do
{opts, _, _} = OptionParser.parse(args, switches: @switches)
if opts[:dry_run] do
dry_run!(opts)
exit(:normal)
end
duration = opts[:duration] || 1800
no_build = opts[:no_build] || false
device =
case opts[:device] || auto_detect_device() do
nil ->
Mix.raise("""
No Android device found. Options:
mix mob.battery_bench_android --device 192.168.1.42:5555
adb connect PHONE_IP:5555 then re-run
""")
d ->
d
end
pkg = MobDev.Config.bundle_id()
app = app_name()
IO.puts("")
IO.puts("=== Mob Battery Benchmark ===")
IO.puts("")
IO.puts(" Device: #{device}")
IO.puts(" Package: #{pkg}")
IO.puts(" Duration: #{duration}s (#{div(duration, 60)} min)")
IO.puts(" Mode: #{describe_mode(opts)}")
IO.puts("")
unless adb_ok?(device) do
Mix.raise("Cannot reach device #{device}. Check: adb connect #{device}")
end
# ── Build ──────────────────────────────────────────────────────────────────
unless no_build do
{extra_cpp_flags, header_dir} = resolve_build_flags(opts)
IO.puts("=== Building APK ===")
build_apk(extra_cpp_flags, header_dir)
IO.puts("=== Installing APK ===")
apk = "android/app/build/outputs/apk/debug/app-debug.apk"
unless File.exists?(apk), do: Mix.raise("APK not found at #{apk}. Build may have failed.")
install_apk(device, apk, pkg)
IO.puts("=== Pushing BEAMs ===")
Mix.Task.run("compile")
push_beams(device, pkg, app)
# Clean up temp header dir
if header_dir, do: File.rm_rf!(header_dir)
end
# ── Pre-run checks ─────────────────────────────────────────────────────────
battery_pct = read_battery_pct(device)
IO.puts("")
IO.puts("Battery level: #{battery_pct}%")
if battery_pct < 80 do
IO.puts("WARNING: Battery below 80%. Charge to >90% for comparable results.")
IO.puts("Continue? (y/N)")
case prompt_yn("") do
"y" -> :ok
_ -> Mix.raise("Aborted.")
end
end
# ── Promote USB → WiFi ADB so the connection survives unplug ──
# If the user passed a USB serial, auto-enable WiFi adb and switch the
# bench's `device` to <ip>:5555. If it's already an IP:port (WiFi adb
# already active), pass through unchanged. Saves the user from the
# tcpip/connect dance manually.
device = ensure_wifi_adb!(device)
IO.puts("")
IO.puts("==========================================")
IO.puts(" Unplug the USB cable now if connected.")
IO.puts(" Press Enter when ready to start the run.")
IO.puts("==========================================")
wait_for_enter()
unless adb_ok?(device) do
Mix.raise("""
Lost connection after unplug.
The bench tried to switch to WiFi ADB automatically; that's failing
now. Common causes:
- Device not on WiFi
- WiFi network blocking ADB port (5555)
- Device's WiFi went to sleep when screen locked
You can do it manually before re-running:
adb -s <USB-SERIAL> tcpip 5555
adb connect <PHONE-WIFI-IP>:5555
mix mob.battery_bench_android --no-build --device <PHONE-WIFI-IP>:5555
""")
end
# ── Benchmark ──────────────────────────────────────────────────────────────
IO.puts("")
IO.puts("=== Resetting battery stats ===")
adb!(device, ~w[shell dumpsys batterystats --reset])
:timer.sleep(2000)
start_mah = read_charge_counter_mah(device)
IO.puts("Start charge: #{start_mah} mAh")
# ── Set up adb tunnels BEFORE launching the app ─────────────────────
# The BEAM tries to register with Mac's EPMD via 127.0.0.1:4369 during
# startup. That works only if the adb reverse tunnel is already up
# before mob_start_beam runs. If we set up tunnels after launch, the
# BEAM has already tried and failed to register, and verify_app_running!
# will (correctly) report "BEAM never registered".
ensure_tunnels(device)
IO.puts("")
IO.puts("=== Launching app ===")
adb!(device, ~w[shell am start -n #{pkg}/#{@android_activity}])
:timer.sleep(3000)
# ── Verify the app actually started ─────────────────────────────────
# If the BEAM crashes on launch (missing native libs, bad flags, etc.)
# the app process disappears within seconds. Catching it here saves a
# 30-minute meaningless run.
verify_app_running!(device, pkg)
# Try the per-device suffixed name first (post-2026-04 deploys), then the
# bare name (back-compat). try_connect_with_retry returns the first node
# that succeeds, or nil if both fail.
suffix = MobDev.Discovery.Android.device_node_suffix(device)
suffixed_node = :"#{app}_android_#{suffix}@127.0.0.1"
bare_node = :"#{app}_android@127.0.0.1"
# Poll Node.connect for up to 10 s. The BEAM's `Mob.Dist` waits ~3 s
# after app launch and only then registers — and the EPMD-name->port
# path can be briefly stale if a previous run held the slot. A single-
# shot connect here would race with all of that and `active_node = nil`
# for the rest of the run, leaving every probe stuck on `:unreachable`
# even when the BEAM is healthy and Erlang dist works fine seconds later.
active_node =
try_connect_with_retry(suffixed_node, 10_000) ||
try_connect_with_retry(bare_node, 2_000)
if active_node && opts[:no_keep_alive] != true do
IO.puts(" Starting background keep-alive...")
:rpc.call(active_node, :mob_nif, :background_keep_alive, [], 5000)
end
# ── Preflight ──────────────────────────────────────────────────────────
unless opts[:skip_preflight] do
IO.puts("")
IO.puts("=== Preflight checks ===")
preflight_results =
Preflight.run(
platform: :android,
node: active_node,
host: "127.0.0.1",
cookie: :mob_secret,
bundle_id: pkg,
adb_serial: device,
require_keep_alive: opts[:no_keep_alive] != true
)
IO.puts(Preflight.pretty(preflight_results))
unless Preflight.all_ok?(preflight_results) do
IO.puts("")
IO.puts(">>> Preflight reported issues. Continue anyway? (y/N)")
case IO.gets("") |> String.trim() do
"y" -> :ok
_ -> Mix.raise("Aborted at preflight.")
end
end
end
screen_off(device)
IO.puts("")
IO.puts("Running for #{div(duration, 60)} min — do not touch the phone...")
IO.puts("")
total_min = div(duration, 60)
start_time = System.monotonic_time(:second)
# ── Open CSV log unless --no-csv ───────────────────────────────────────
log =
if opts[:no_csv] do
nil
else
log_path =
opts[:log_path] ||
Path.join([
File.cwd!(),
"_build",
"bench",
"run_android_#{System.os_time(:second)}.csv"
])
IO.puts(" Logging samples to #{log_path}")
Logger.open(log_path, start_ts_ms: System.monotonic_time(:millisecond))
end
reconnector = Reconnector.new(active_node || :unset@unset, :mob_secret)
observer =
DeviceObserver.subscribe(active_node, categories: [:app, :display, :memory])
if observer.subscribed? do
IO.puts(" Subscribed to Mob.Device events on #{inspect(active_node)}")
end
{final_log, _final_reconnector, _final_observer} =
Enum.reduce(1..duration, {log, reconnector, observer}, fn i,
{log_acc, recon_acc, obs_acc} ->
:timer.sleep(1000)
if rem(i, 10) == 0 do
poll_tick(
log_acc,
recon_acc,
obs_acc,
node: active_node,
host: "127.0.0.1",
adb_serial: device,
bundle_id: pkg,
expected_screen: :off,
start_time: start_time,
start_mah: start_mah,
total_min: total_min
)
else
{log_acc, recon_acc, DeviceObserver.consume_messages(obs_acc)}
end
end)
log = final_log
# ── Results ────────────────────────────────────────────────────────────────
IO.puts("")
IO.puts("=== Collecting results ===")
adb!(device, ~w[shell am force-stop #{pkg}])
:timer.sleep(1000)
end_mah = read_charge_counter_mah(device)
end_pct = read_battery_pct(device)
drain_mah = start_mah - end_mah
elapsed_actual = System.monotonic_time(:second) - start_time
rate =
if elapsed_actual > 0,
do: Float.round(drain_mah * 3600 / elapsed_actual, 1),
else: 0.0
IO.puts("")
IO.puts("=== Summary: #{describe_mode(opts)} ===")
IO.puts("")
IO.puts(" Duration: #{div(elapsed_actual, 60)} min #{rem(elapsed_actual, 60)} sec")
IO.puts(" Start: #{start_mah} mAh (#{battery_pct}%)")
IO.puts(" End: #{end_mah} mAh (#{end_pct}%)")
IO.puts(" Drain: #{drain_mah} mAh")
IO.puts(" Rate: #{rate} mAh/hr")
IO.puts("")
IO.puts("Lower mAh/hr = better. No-BEAM baseline is ~200 mAh/hr on Moto G.")
IO.puts("")
# ── CSV-based summary ───────────────────────────────────────────────
if log do
log_path = log.path
Logger.close(log)
IO.puts("=== Probe-based summary ===")
IO.puts("")
try do
metrics = Summary.from_csv(log_path)
IO.puts(Summary.pretty(metrics))
IO.puts("")
IO.puts("Full log: #{log_path}")
rescue
e -> IO.puts(" (could not parse #{log_path}: #{Exception.message(e)})")
end
IO.puts("")
end
end
# ── Probe-driven poll tick ────────────────────────────────────────────────
defp poll_tick(log, reconnector, observer, opts) do
elapsed_sec = System.monotonic_time(:second) - opts[:start_time]
elapsed_min = Float.round(elapsed_sec / 60, 1)
ts = time_string()
observer = DeviceObserver.consume_messages(observer)
probe =
Probe.snapshot(
platform: :android,
node: opts[:node],
host: opts[:host],
adb_serial: opts[:adb_serial],
bundle_id: opts[:bundle_id],
expected_screen: opts[:expected_screen]
)
probe = DeviceObserver.apply_to_probe(observer, probe)
log = if log, do: Logger.append(log, probe), else: log
fragment = Probe.format(probe)
line =
case probe.battery_pct do
nil ->
" [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment}"
pct ->
# Note: Android USB probe returns battery percentage. We separately
# track mAh via dumpsys for the Android-specific drain calculation
# below, but the live trace uses % to align with iOS bench output.
" [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment} (#{pct}%)"
end
IO.puts(line)
now_ms = System.monotonic_time(:millisecond)
reconnector =
case Reconnector.tick(reconnector, probe, now_ms) do
{:no_action, r} ->
r
{:attempt, r} ->
if opts[:node] && Node.connect(opts[:node]) do
IO.puts(
" ↻ reconnected to #{opts[:node]} (attempt #{r.attempts}, total #{r.total_reconnects + 1})"
)
Reconnector.record_success(r)
else
r
end
end
{log, reconnector, observer}
end
# ── Dry run ───────────────────────────────────────────────────────────────────
defp dry_run!(opts) do
pkg = MobDev.Config.bundle_id()
duration = opts[:duration] || 1800
# Validate preset / flags (raises on bad preset name)
{cflags, header_dir} = resolve_build_flags(opts)
if header_dir, do: File.rm_rf!(header_dir)
IO.puts("")
IO.puts("=== Mob Battery Benchmark (Android) — Dry Run ===")
IO.puts("")
IO.puts(" Device: #{opts[:device] || "(auto-detect at run time)"}")
IO.puts(" Package: #{pkg}")
IO.puts(" Duration: #{duration}s (#{div(duration, 60)} min)")
IO.puts(" Mode: #{describe_mode(opts)}")
IO.puts(" Flags: #{if cflags == "", do: "(default Nerves tuning)", else: cflags}")
IO.puts(" Build: #{if opts[:no_build], do: "skip (--no-build)", else: "yes"}")
IO.puts("")
IO.puts("Dry run complete — no prerequisites checked, no device contacted.")
IO.puts("")
end
# ── Build flags ──────────────────────────────────────────────────────────────
# Returns {extra_cpp_flags_string, header_temp_dir_or_nil}
@doc false
@spec resolve_build_flags(keyword()) :: {String.t(), String.t() | nil}
def resolve_build_flags(opts) do
cond do
opts[:no_beam] ->
{"-DNO_BEAM", nil}
opts[:flags] ->
header_dir = Path.join(System.tmp_dir!(), "mob_bench_flags_#{System.os_time(:second)}")
File.mkdir_p!(header_dir)
flags_list = String.split(opts[:flags], Regex.compile!("\\s+"), trim: true)
c_literals = Enum.map_join(flags_list, ", ", &~s("#{&1}"))
header =
"/* generated by mix mob.battery_bench_android -- do not edit */\n" <>
"#define BEAM_EXTRA_FLAGS #{c_literals},\n"
File.write!(Path.join(header_dir, "mob_beam_flags.h"), header)
{"-DBEAM_USE_CUSTOM_FLAGS -I#{header_dir}", header_dir}
opts[:preset] ->
flag =
case opts[:preset] do
"untuned" -> "-DBEAM_UNTUNED"
"sbwt" -> "-DBEAM_SBWT_ONLY"
"nerves" -> "-DBEAM_FULL_NERVES"
other -> Mix.raise("Unknown preset #{inspect(other)}. Choose: untuned, sbwt, nerves")
end
{flag, nil}
true ->
# Production default (full Nerves tuning)
{"", nil}
end
end
@doc false
@spec describe_mode(keyword()) :: String.t()
def describe_mode(opts) do
cond do
opts[:no_beam] -> "no-beam (baseline)"
opts[:flags] -> "custom flags: #{opts[:flags]}"
opts[:preset] -> "preset: #{opts[:preset]}"
true -> "default (Nerves tuning)"
end
end
# ── APK build ────────────────────────────────────────────────────────────────
defp build_apk(extra_cpp_flags, _header_dir) do
android_dir = Path.join(File.cwd!(), "android")
gradlew = Path.join(android_dir, "gradlew")
unless File.exists?(gradlew), do: Mix.raise("gradlew not found at #{gradlew}")
IO.puts(" Running Gradle assembleDebug...")
args =
["assembleDebug", "-q"] ++
if extra_cpp_flags != "", do: ["-PextraCppFlags=#{extra_cpp_flags}"], else: []
case System.cmd(gradlew, args, cd: android_dir, stderr_to_stdout: true, into: IO.stream()) do
{_, 0} -> :ok
{_, _} -> Mix.raise("Gradle assembleDebug failed — check output above")
end
end
defp install_apk(device, apk, pkg) do
IO.puts(" Stopping app...")
adb(device, ~w[shell am force-stop #{pkg}])
IO.puts(" Installing #{apk}...")
# `adb install -r` replaces the APK in-place. It re-extracts native libs
# to /data/app/<pkg>/lib/<abi>/ but preserves /data/data/<pkg>/, which is
# critical: that directory holds files/otp/erts-*/bin/ — pushed by
# `mix mob.deploy --native` during initial provisioning. A previous
# version of this code did `adb uninstall && adb install`, which nuked
# /data/data/ and left the device with no ERTS, so mob_start_beam would
# crash on every subsequent launch with "symlink erl_child_setup failed".
#
# Falls back to uninstall+install on signature mismatch
# (INSTALL_FAILED_UPDATE_INCOMPATIBLE) — that path will rebuild the OTP
# runtime via the next `mix mob.deploy --native`, but the user has been
# warned.
case adb(device, ~w[install -r #{apk}]) do
{:ok, out} ->
if String.contains?(out, "INSTALL_FAILED") do
handle_install_failure(device, apk, pkg, out)
else
:ok
end
{:error, reason} ->
if String.contains?(reason, "INSTALL_FAILED_UPDATE_INCOMPATIBLE") do
handle_install_failure(device, apk, pkg, reason)
else
Mix.raise("APK install failed: #{reason}")
end
end
end
# When `install -r` fails because the new APK has a different signing
# certificate from the installed one, fall back to uninstall + install. This
# destroys /data/data/<pkg>/ and any OTP runtime there, so warn the user
# they'll need to rerun `mix mob.deploy --native` to restore ERTS before
# launching the app again.
defp handle_install_failure(device, apk, pkg, reason) do
IO.puts(
" #{IO.ANSI.yellow()}⚠ install -r failed: #{String.slice(reason, 0, 200)}#{IO.ANSI.reset()}"
)
IO.puts(" Falling back to full uninstall+install. This will erase the")
IO.puts(" OTP runtime in /data/data/#{pkg}/files/. After the bench finishes,")
IO.puts(" re-run `mix mob.deploy --native --device #{device}` to restore ERTS.")
adb(device, ~w[uninstall #{pkg}])
case adb(device, ~w[install #{apk}]) do
{:ok, _} -> :ok
{:error, why} -> Mix.raise("APK install failed: #{why}")
end
end
# ── BEAM push ────────────────────────────────────────────────────────────────
defp push_beams(device, pkg, app) do
beam_dirs = collect_beam_dirs()
beams_dir = "/data/data/#{pkg}/files/otp/#{app}"
# Check if we can root
rooted? =
case adb(device, ["root"]) do
{:ok, out} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
:timer.sleep(600)
adb!(device, ~w[shell mkdir -p #{beams_dir}])
Enum.each(beam_dirs, fn dir ->
adb!(device, ["push", "#{dir}/.", "#{beams_dir}/"])
end)
else
push_beams_runas(device, pkg, beams_dir, beam_dirs)
end
end
defp push_beams_runas(device, pkg, beams_dir, beam_dirs) do
stage_local = Path.join(System.tmp_dir!(), "mob_bench_beams.tar")
stage_device = "/data/local/tmp/mob_bench_beams.tar"
tmp = Path.join(System.tmp_dir!(), "mob_bench_stage")
File.rm_rf!(tmp)
File.mkdir_p!(tmp)
Enum.each(beam_dirs, fn dir -> System.cmd("cp", ["-r", "#{dir}/.", tmp]) end)
System.cmd("tar", ["cf", stage_local, "-C", Path.dirname(tmp), Path.basename(tmp)])
adb!(device, ["push", stage_local, stage_device])
adb!(device, ~w[shell run-as #{pkg} mkdir -p #{beams_dir}])
cmd = "run-as #{pkg} tar xf #{stage_device} -C #{beams_dir}/ --strip-components=1"
adb!(device, ["shell", cmd])
adb(device, ~w[shell rm -f #{stage_device}])
File.rm(stage_local)
File.rm_rf!(tmp)
end
defp collect_beam_dirs do
case File.ls("_build/dev/lib") do
{:ok, libs} ->
libs
|> Enum.map(&"_build/dev/lib/#{&1}/ebin")
|> Enum.filter(&File.dir?/1)
{:error, _} ->
[]
end
end
# ── Screen off ───────────────────────────────────────────────────────────────
defp screen_off(device) do
IO.puts("=== Turning screen off ===")
# Check current state, press KEYCODE_POWER (26) to toggle off.
# If it ended up on (was already off before), press again.
adb!(device, ~w[shell input keyevent 26])
:timer.sleep(1000)
screen = adb_out(device, ~w[shell dumpsys display])
if screen =~ Regex.compile!("mScreenState.*ON", "i") or
screen =~ Regex.compile!("mState.*ON", "i") do
adb!(device, ~w[shell input keyevent 26])
end
IO.puts(" Screen off.")
end
# ── Battery readings ─────────────────────────────────────────────────────────
# Charge counter in µAh → divide by 1000 for mAh.
# Falls back to percentage-based estimate if charge counter is unavailable.
defp read_charge_counter_mah(device) do
out = adb_out(device, ~w[shell dumpsys battery])
case Regex.run(Regex.compile!("Charge counter:\\s*(\\d+)"), out) do
[_, uah] ->
div(String.to_integer(uah), 1000)
nil ->
# Fallback: no charge counter on this device
read_battery_pct(device)
end
end
defp read_battery_pct(device) do
out = adb_out(device, ~w[shell dumpsys battery])
case Regex.run(Regex.compile!("level:\\s*(\\d+)"), out) do
[_, pct] -> String.to_integer(pct)
nil -> 0
end
end
# ── ADB helpers ──────────────────────────────────────────────────────────────
defp auto_detect_device do
case System.cmd("adb", ["devices"], stderr_to_stdout: true) do
{output, 0} ->
output
|> String.split("\n")
|> Enum.drop(1)
|> Enum.filter(&String.contains?(&1, "\tdevice"))
|> Enum.map(&(&1 |> String.split("\t") |> hd() |> String.trim()))
|> List.first()
_ ->
nil
end
end
defp adb_ok?(device) do
case System.cmd("adb", ["-s", device, "shell", "echo", "ok"], stderr_to_stdout: true) do
{_, 0} -> true
_ -> false
end
end
defp adb(device, args) do
case System.cmd("adb", ["-s", device | args], stderr_to_stdout: true) do
{out, 0} -> {:ok, String.trim(out)}
{out, _} -> {:error, String.trim(out)}
end
end
defp adb!(device, args) do
case adb(device, args) do
{:ok, out} ->
out
{:error, reason} ->
IO.puts(" adb warning: #{reason}")
""
end
end
defp adb_out(device, args) do
case System.cmd("adb", ["-s", device | args], stderr_to_stdout: true) do
{out, _} -> out
end
end
# ── Misc ──────────────────────────────────────────────────────────────────────
defp app_name, do: Mix.Project.config()[:app] |> to_string()
# IO.gets returns :eof in non-interactive contexts (piped stdin, certain
# CI runners). Treat EOF as "no answer" rather than crashing in
# String.trim/1.
defp prompt_yn(prompt) do
case IO.gets(prompt) do
:eof -> "n"
{:error, _} -> "n"
str when is_binary(str) -> str |> String.trim() |> String.downcase()
end
end
defp wait_for_enter do
case IO.gets("") do
:eof ->
IO.puts(" (stdin not interactive — proceeding without confirmation)")
:ok
{:error, _} ->
:ok
_ ->
:ok
end
end
# Verify both that (a) the Android process is up and (b) the BEAM has
# finished booting and registered its node in EPMD with a *live* listener.
# Catches four failure modes:
# 1. App crashes immediately → pidof returns empty
# 2. App shell up, BEAM crashed → pidof returns pid, EPMD never has node
# 3. Stale EPMD entry from prior → EPMD has node but TCP-probe fails
# run (different device, etc.) (the listener at the registered
# port isn't actually accepting)
# 4. Healthy startup → pidof + live EPMD entry both succeed
#
# The stale-entry case is especially nasty: another device/run can leave a
# name registered in Mac's EPMD that points to a port nothing is listening
# on. Without the TCP probe, the bench thinks the BEAM is up and lets a
# 30-minute run proceed where every RPC will fail.
defp verify_app_running!(device, pkg) do
app = app_name()
base_name = "#{app}_android"
suffix = MobDev.Discovery.Android.device_node_suffix(device)
suffixed_name = "#{base_name}_#{suffix}"
# Try the suffixed name first (post-2026-04 deploys). Fall back to the
# bare name for back-compat with apps deployed before per-device suffixes.
candidates = [suffixed_name, base_name]
deadline_ms = System.monotonic_time(:millisecond) + 10_000
result =
verify_loop(device, pkg, candidates, deadline_ms,
last_pid: nil,
last_epmd_entries: nil,
matched_name: nil
)
case result do
{:ok, pid, port, matched} ->
IO.puts(" ✓ App running on device (pid #{pid})")
IO.puts(" ✓ BEAM registered in EPMD as #{matched} (port #{port})")
{:error, :no_process, _state} ->
Mix.raise(crash_diagnosis_no_process(device, pkg))
{:error, :process_no_beam, state} ->
Mix.raise(crash_diagnosis_no_beam(device, pkg, state[:last_pid]))
# Stale EPMD entry isn't fatal — the bench can still run with USB-only
# battery readings. Warn loudly so the user knows BEAM-driven probes
# (RPC, NIF version checks) won't work, then fall through.
{:error, :stale_epmd, state} ->
IO.puts(" ✓ App running on device (pid #{state[:last_pid]})")
IO.puts(
" #{IO.ANSI.yellow()}⚠ EPMD has #{state[:matched_name]} at port #{state[:stale_port]} but Node.connect fails#{IO.ANSI.reset()}"
)
IO.puts(stale_epmd_recovery_hint(device, pkg))
end
end
defp verify_loop(device, pkg, candidates, deadline_ms, state) do
:timer.sleep(500)
pid = pid_of(device, pkg)
epmd_entries = epmd_names_local()
matched =
Enum.find_value(candidates, fn name ->
case Map.get(epmd_entries, name) do
nil -> nil
port -> {name, port}
end
end)
cond do
pid && matched && beam_reachable?(:"#{elem(matched, 0)}@127.0.0.1") ->
{name, port} = matched
{:ok, pid, port, name}
System.monotonic_time(:millisecond) >= deadline_ms ->
cond do
is_nil(pid) ->
{:error, :no_process, [last_pid: state[:last_pid], last_epmd_entries: epmd_entries]}
# EPMD has an entry but Node.connect can't actually reach the BEAM
# — typically a stale entry from a prior run, or another device
# squatting on the same name (only possible with bare base name).
matched ->
{name, port} = matched
{:error, :stale_epmd,
[
last_pid: pid,
last_epmd_entries: epmd_entries,
stale_port: port,
matched_name: name
]}
true ->
{:error, :process_no_beam, [last_pid: pid, last_epmd_entries: epmd_entries]}
end
true ->
verify_loop(device, pkg, candidates, deadline_ms,
last_pid: pid || state[:last_pid],
last_epmd_entries: epmd_entries,
matched_name: state[:matched_name]
)
end
end
defp pid_of(device, pkg) do
case System.cmd("adb", ["-s", device, "shell", "pidof", pkg], stderr_to_stdout: true) do
{out, 0} ->
case String.trim(out) do
"" -> nil
s -> s
end
_ ->
nil
end
end
# Returns a map of %{node_name => port} for everything Mac's EPMD knows.
defp epmd_names_local do
case :gen_tcp.connect(~c"127.0.0.1", 4369, [:binary, active: false], 500) do
{:ok, sock} ->
:gen_tcp.send(sock, <<0, 1, ?n>>)
entries =
case :gen_tcp.recv(sock, 0, 500) do
{:ok, <<_::32, body::binary>>} ->
body
|> String.split("\n", trim: true)
|> Enum.flat_map(fn line ->
case Regex.run(Regex.compile!("^name (\\S+) at port (\\d+)$"), line) do
[_, name, port] -> [{name, String.to_integer(port)}]
_ -> []
end
end)
|> Map.new()
_ ->
%{}
end
:gen_tcp.close(sock)
entries
_ ->
%{}
end
end
# Confirm the registered BEAM is actually reachable over Erlang
# distribution — the only check that distinguishes a live BEAM from a
# stale EPMD entry. A plain TCP-connect on the registered port is
# unreliable here because `adb forward` accepts host-side connections
# eagerly and only later finds out the device-side socket is dead, so a
# raw `gen_tcp:connect/3` returns `:ok` even when nothing is listening
# inside the app.
defp beam_reachable?(node) do
Node.set_cookie(node, :mob_secret)
Node.connect(node) == true
rescue
_ -> false
end
# Repeatedly try Node.connect until success or timeout. Used right after
# `verify_app_running!` to handle the timing window where the device-side
# `Mob.Dist` is still bringing up its listener — a single Node.connect
# would fail and leave `active_node = nil` for the entire run, sending
# every probe to `:unreachable` even when the BEAM is healthy.
#
# Returns the connected node atom on success, nil on timeout. The bench
# tries the per-device suffixed name first then falls back to the bare
# name; returning the actual node lets the caller pick whichever worked.
defp try_connect_with_retry(node, timeout_ms) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
do_try_connect(node, deadline, _attempts = 0)
end
defp do_try_connect(node, deadline, attempts) do
if beam_reachable?(node) do
IO.puts(" BEAM connected: #{node}")
node
else
if System.monotonic_time(:millisecond) < deadline do
:timer.sleep(500)
do_try_connect(node, deadline, attempts + 1)
else
IO.puts(" (BEAM not reachable after #{attempts + 1} attempts — USB-only readings)")
nil
end
end
end
defp crash_diagnosis_no_process(device, pkg) do
"""
✗ App #{pkg} is not running ~10 seconds after launch.
The Android process is gone — BEAM crashed before the iOS shell could
keep it alive. Common causes:
- Missing ERTS helper libs in the APK (check lib/<abi>/ contains
liberl_child_setup.so, libinet_gethost.so, libepmd.so — for
32-bit ARM devices they need to be in lib/arm, not just
lib/arm64).
- Bad BEAM flags in mob.exs (try `mix mob.deploy --beam-flags ""`)
- App crashed for an unrelated reason — check logcat:
adb -s #{device} logcat -d | grep -iE "MobBeam|MobNIF|FATAL|tombstone"
Re-run the bench after the app launches cleanly.
"""
end
defp crash_diagnosis_no_beam(device, pkg, pid) do
"""
✗ App #{pkg} is running (pid #{pid}) but the BEAM never registered.
The Android process is alive but the embedded BEAM either crashed
during startup or isn't reachable via Erlang distribution. Common
causes:
- BEAM crashed in mob_start_beam — check logcat for SIGABRT in
beam-main:
adb -s #{device} logcat -d | grep -iE "MobBeam|FATAL|SIGABRT|beam-main"
- OTP runtime never deployed to this device. The app is installed
but /data/data/<pkg>/files/otp/erts-*/bin/ is missing. Common when
the device wasn't connected during a previous `mix mob.deploy
--native`. Provision it now:
mix mob.deploy --native --device #{device}
- BEAMs stale on device. If OTP is present, push fresh BEAMs:
mix mob.deploy --android --device #{device}
- Bad BEAM flags in mob.exs (try `mix mob.deploy --beam-flags ""`)
- adb tunnels not set up (the bench tries automatically; if your
Mac's EPMD is occupied by another node, things may collide)
The Android process may be the foreground service / notification
process keeping the package alive even though the BEAM died. Don't
take a green `pidof` as proof the BEAM is up — EPMD registration is
the authoritative signal.
"""
end
defp stale_epmd_recovery_hint(device, pkg) do
others = other_devices_running(device, pkg)
collision_block =
case others do
[] ->
"""
No other adb-connected device appears to be running #{pkg}, so
the EPMD entry is most likely stale (left by a previous run).
"""
_ ->
formatted =
Enum.map_join(others, "\n", fn {serial, pid} ->
" adb -s #{serial} shell am force-stop #{pkg} # pid #{pid}"
end)
"""
Other adb-connected device(s) are also running #{pkg} — they're
holding the EPMD `<app>_android` slot. Force-stop them so this
bench's BEAM can register, OR disconnect those devices:
#{formatted}
(Each Android device hardcodes the same node name, so only one
can register in Mac's EPMD via adb-reverse at a time. The
structural fix is per-device unique node names, like iOS sims
do with their UDID suffix — not yet implemented.)
"""
end
"""
Bench will fall back to USB-only readings (no per-second RPC probes).
#{collision_block}
Other recovery options:
# Force EPMD to forget every node (kills any other Mob iEx sessions):
pkill -9 epmd && epmd -daemon
adb -s #{device} reverse --remove-all && \\
adb -s #{device} reverse tcp:4369 tcp:4369
Logcat tells you whether the BEAM tried distribution this run:
adb -s #{device} logcat -d | grep -iE "Mob.Dist|step [0-9]"
"""
end
# Walk every adb-connected device, check whether it has `pkg` running, and
# return the [{serial, pid}] list excluding the bench's own target. Used to
# tell the user which other phone is squatting on the EPMD slot.
defp other_devices_running(this_device, pkg) do
case System.cmd("adb", ["devices"], stderr_to_stdout: true) do
{output, 0} ->
output
|> String.split("\n")
|> Enum.drop(1)
|> Enum.filter(&String.contains?(&1, "\tdevice"))
|> Enum.map(&hd(String.split(&1, "\t")))
|> Enum.reject(&same_device?(&1, this_device))
|> Enum.flat_map(fn serial ->
case System.cmd("adb", ["-s", serial, "shell", "pidof", pkg], stderr_to_stdout: true) do
{out, 0} ->
case String.trim(out) do
"" -> []
pid -> [{serial, pid}]
end
_ ->
[]
end
end)
_ ->
[]
end
end
# Two adb identifiers refer to the same physical device when one is the
# USB serial and the other is `<ip>:5555` for the same phone. We can't
# always tell that from the strings alone, so be lenient: equal-string match
# plus IP-port form for the bench's own device.
defp same_device?(serial, this_device) do
serial == this_device or
serial == strip_port(this_device) or
"#{serial}:5555" == this_device
end
defp strip_port(s) do
case String.split(s, ":", parts: 2) do
[host, _port] -> host
_ -> s
end
end
# If the user passed a USB serial (no IP:port), auto-enable WiFi ADB so
# the bench's `device` argument keeps working after the user unplugs the
# USB cable. Returns the (possibly-promoted) device identifier.
#
# Steps:
# 1. Detect device is USB-connected (serial doesn't match IP:port format)
# 2. Find its WiFi IP via `adb shell ip route get 1.1.1.1`
# 3. `adb -s SERIAL tcpip 5555` to enable WiFi adb
# 4. Sleep briefly for the device to switch
# 5. `adb connect IP:5555`
# 6. Verify the IP:5555 connection works
# 7. Return "IP:5555" — caller uses this for all subsequent adb commands
#
# If anything fails along the way, raise with a clear hint to do it
# manually rather than surprising the user later when unplug fails.
defp ensure_wifi_adb!(device) do
if String.contains?(device, ":") do
# Already IP:port — assume user has WiFi adb working.
device
else
promote_usb_to_wifi!(device)
end
end
defp promote_usb_to_wifi!(serial) do
IO.puts("")
IO.puts("=== Switching to WiFi ADB ===")
IO.puts(" Finding device WiFi IP...")
ip = wifi_ip_for_serial!(serial)
IO.puts(" Device IP: #{ip}")
IO.puts(" Enabling WiFi ADB on port 5555...")
case System.cmd("adb", ["-s", serial, "tcpip", "5555"], stderr_to_stdout: true) do
{_, 0} ->
:ok
{out, _} ->
Mix.raise("""
Failed to enable WiFi ADB:
#{String.trim(out)}
Try manually:
adb -s #{serial} tcpip 5555
adb connect <PHONE-IP>:5555
""")
end
# Device needs a moment to restart adbd in TCP mode.
:timer.sleep(2_000)
new_device = "#{ip}:5555"
IO.puts(" Connecting to #{new_device}...")
case System.cmd("adb", ["connect", new_device], stderr_to_stdout: true) do
{out, 0} ->
if String.contains?(out, "connected") or String.contains?(out, "already connected") do
# Verify it actually works.
if adb_ok?(new_device) do
IO.puts(" ✓ WiFi ADB connected as #{new_device}")
new_device
else
Mix.raise("""
adb connect reported success but the device isn't responding.
Check WiFi network and re-run with the WiFi-ADB serial:
mix mob.battery_bench_android --no-build --device #{new_device}
""")
end
else
Mix.raise("""
adb connect failed:
#{String.trim(out)}
""")
end
{out, _} ->
Mix.raise("""
adb connect failed:
#{String.trim(out)}
Try manually:
adb -s #{serial} tcpip 5555
adb connect #{new_device}
""")
end
end
# Find the device's WiFi IPv4 by running `ip route get 1.1.1.1` on it
# and parsing the `src` field from the output.
defp wifi_ip_for_serial!(serial) do
case System.cmd("adb", ["-s", serial, "shell", "ip", "route", "get", "1.1.1.1"],
stderr_to_stdout: true
) do
{out, 0} ->
case Regex.run(
Regex.compile!("\\bsrc\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})"),
out
) do
[_, ip] ->
ip
nil ->
Mix.raise("""
Couldn't determine the device's WiFi IP from:
#{String.trim(out)}
Is the device connected to WiFi? Settings → Network & internet → Internet.
""")
end
{out, _} ->
Mix.raise("""
adb shell ip route failed:
#{String.trim(out)}
""")
end
end
# Set up the adb tunnels needed for Erlang dist:
# adb reverse tcp:4369 tcp:4369 — Android BEAM registers in Mac's EPMD
# adb forward tcp:9100 tcp:9100 — Mac reaches device's dist port
# No-op on failure — the bench will detect the missing connection during
# preflight and the user can investigate.
defp ensure_tunnels(serial) when is_binary(serial) do
System.cmd("adb", ["-s", serial, "reverse", "tcp:4369", "tcp:4369"], stderr_to_stdout: true)
System.cmd("adb", ["-s", serial, "forward", "tcp:9100", "tcp:9100"], stderr_to_stdout: true)
# Local Erlang dist must be alive for Node.connect/1 to work.
unless Node.alive?() do
Node.start(:"mob_bench_android@127.0.0.1", :longnames)
Node.set_cookie(:mob_secret)
end
:ok
end
defp time_string do
{{_y, _mo, _d}, {h, m, s}} = :calendar.local_time()
:io_lib.format("~2..0B:~2..0B:~2..0B", [h, m, s]) |> IO.iodata_to_binary()
end
end