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_.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//files/otp// # Reset battery stats and launch adb shell dumpsys batterystats --reset adb shell am start -n /.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 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 :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 tcpip 5555 adb connect :5555 mix mob.battery_bench_android --no-build --device :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 || "(NOT SET)"}") 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//lib// but preserves /data/data//, 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// 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// 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//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 `_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 `: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 :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