Packages
mob_dev
0.3.35
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/discovery/android.ex
defmodule MobDev.Discovery.Android do
@moduledoc "Discovers Android devices and emulators via adb."
alias MobDev.Device
@doc "Returns a list of %Device{} for all adb-visible Android devices."
@spec list_devices() :: [Device.t()]
def list_devices do
case System.find_executable("adb") do
nil -> []
_ -> do_list()
end
end
defp do_list do
case run_adb(["devices", "-l"]) do
{:ok, output} ->
output
|> parse_devices_output()
|> Enum.map(&enrich/1)
{:error, _} ->
[]
end
end
@doc """
Parses the raw output of `adb devices -l` into a list of `%Device{}`.
Does not perform enrichment (no adb calls for name/version).
Exposed for testing.
"""
@spec parse_devices_output(String.t()) :: [Device.t()]
def parse_devices_output(output) do
output
|> String.split("\n")
# skip "List of devices attached" header
|> Enum.drop(1)
|> Enum.reject(&(String.trim(&1) == ""))
|> Enum.map(&parse_device_line/1)
|> Enum.reject(&is_nil/1)
end
# Parse lines like:
# emulator-5554 device product:sdk_gphone64_arm64 ...
# R5CW3089HVB unauthorized
# 192.168.1.5:5555 device
defp parse_device_line(line) do
# adb's output uses both spaces and tabs as field separators; split on
# the first whitespace run via a runtime-compiled regex. Compile-time
# `~r/\s+/` literals are unsafe on Elixir 1.19+ / OTP 28.0 because they
# rely on `:re.import/1`, which OTP 28.0 removed.
case String.split(line, Regex.compile!("\\s+"), parts: 2) do
[serial, rest] ->
cond do
String.contains?(rest, "unauthorized") ->
%Device{
platform: :android,
serial: serial,
status: :unauthorized,
error: "USB debugging not authorized — check device for prompt"
}
String.contains?(rest, "offline") ->
nil
String.starts_with?(rest, "device") or String.starts_with?(rest, "no permissions") ->
type = if String.starts_with?(serial, "emulator"), do: :emulator, else: :physical
%Device{platform: :android, serial: serial, type: type, status: :discovered}
true ->
nil
end
_ ->
nil
end
end
defp enrich(%Device{status: :unauthorized} = d), do: d
defp enrich(%Device{serial: serial} = d) do
name = getprop(serial, "ro.product.model")
version = getprop(serial, "ro.build.version.release")
# Compute the node name from the device's stable hardware serial
# (`ro.serialno`) rather than the adb id we used to talk to it, so the
# node atom is the same whether we connected over USB or WiFi-adb.
# Falls back to Device.node_name/1 (pure, sanitizes Device.serial) if
# getprop fails — keeps a deterministic answer even on quirky devices.
node =
case getprop(serial, "ro.serialno") do
hw when is_binary(hw) and hw != "" ->
app = Mix.Project.config()[:app]
:"#{app}_android_#{node_suffix_for(hw)}@127.0.0.1"
_ ->
Device.node_name(d)
end
# Skip IP discovery for emulators — `ip route get` returns the
# emulator's internal NAT subnet (10.0.2.x) which isn't reachable
# from the host and just creates confusion in the devices listing.
host_ip = if d.type == :emulator, do: nil, else: device_ip(serial)
%{d | name: name, version: "Android #{version}", node: node, host_ip: host_ip}
end
defp getprop(serial, prop) do
case run_adb(["-s", serial, "shell", "getprop", prop]) do
{:ok, val} -> String.trim(val)
_ -> nil
end
end
# Returns the device's WiFi IPv4 address, or nil if unavailable.
# WiFi-adb-connected devices have IP:port as their serial — extract from there.
# USB-connected devices need a shell call: `ip route get 1` returns a line
# whose 7th token is the device's WiFi IP for outbound traffic.
defp device_ip(serial) do
case extract_ip_from_serial(serial) do
nil -> shell_ip(serial)
ip -> ip
end
end
defp extract_ip_from_serial(serial) do
case Regex.run(Regex.compile!("^(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}):\\d+$"), serial) do
[_, ip] -> ip
_ -> nil
end
end
# Try `ip route get 1.1.1.1` — returns the source IP used for outbound
# traffic. More portable than parsing `ip addr show wlan0` since the
# interface name varies (wlan0, rmnet_data*, etc.).
defp shell_ip(serial) do
case run_adb(["-s", serial, "shell", "ip", "route", "get", "1.1.1.1"]) do
{:ok, out} ->
case Regex.run(
Regex.compile!("\\bsrc\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})"),
out
) do
[_, ip] -> ip
_ -> nil
end
_ ->
nil
end
end
@doc """
Check if developer mode is enabled on the device.
Returns :enabled | :disabled | :unknown.
"""
@spec developer_mode(String.t()) :: :enabled | :disabled | :unknown
def developer_mode(serial) do
case run_adb([
"-s",
serial,
"shell",
"settings",
"get",
"global",
"development_settings_enabled"
]) do
{:ok, "1\n"} -> :enabled
{:ok, "0\n"} -> :disabled
_ -> :unknown
end
end
@doc """
Restarts the app on the device, optionally passing a dist_port intent extra.
Runs `chcon` before `am start` to heal any SELinux MCS category mismatch on OTP
files. This mismatch happens when the APK is reinstalled and Android assigns a new
MCS category to the package — files pushed via `adb push` retain the old label and
the BEAM can't access them.
The label is copied from the app's `cache/` directory, not `files/`. On Android 15
the `files/` directory itself lacks MCS categories (`s0` only), whereas `cache/`
always carries the full `s0:cXXX,cYYY` label that installd assigns to the package.
The `chcon` requires root (`adb root`) — it's silently skipped on non-rooted devices
where the OTP files were pushed with the correct label to begin with.
"""
@spec restart_app(String.t(), String.t(), String.t(), keyword()) ::
{:ok, String.t()} | {:error, String.t()}
def restart_app(serial, package, activity, opts \\ []) do
dist_port = Keyword.get(opts, :dist_port, 9100)
node_suffix = Keyword.get(opts, :node_suffix) || device_node_suffix(serial)
app_data = "/data/data/#{package}/files"
app_cache = "/data/data/#{package}/cache"
run_adb(["-s", serial, "shell", "am", "force-stop", package])
# Read MCS label from cache/ (has full s0:cXXX,cYYY) not files/ (bare s0 on Android 15).
run_adb(["-s", serial, "shell", "chcon -hR $(stat -c %C #{app_cache}) #{app_data}/otp"])
:timer.sleep(300)
run_adb([
"-s",
serial,
"shell",
"am",
"start",
"-n",
"#{package}/#{activity}",
"--ei",
"mob_dist_port",
to_string(dist_port),
"--es",
"mob_node_suffix",
node_suffix
])
end
@doc """
Sanitizes a string into a Mob node-name suffix. Pure — no adb calls.
node_suffix_for("ZY22CRLMWK") → "zy22crlmwk"
node_suffix_for("10.0.0.82:5555") → "10_0_0_82"
node_suffix_for("emulator-5554") → "emulator_5554"
Used as the final transformation step by `device_node_suffix/1` (which
asks the device for a stable hardware serial and runs it through here).
Tests use it directly to verify the sanitization rules.
"""
@spec node_suffix_for(String.t()) :: String.t()
def node_suffix_for(serial) when is_binary(serial) do
# Strip the :port (WiFi-adb form), then sanitize: lowercase, replace any
# non-alphanumeric run with a single underscore, trim leading/trailing.
serial
|> String.split(":", parts: 2)
|> hd()
|> String.downcase()
|> String.replace(Regex.compile!("[^a-z0-9]+"), "_")
|> String.trim("_")
end
@doc """
Returns the Mob node-name suffix for the device reachable via the given
adb identifier. The suffix is derived from the device's hardware serial
(`ro.serialno`), which is stable across USB and WiFi-adb identifiers for
the same physical phone — so a deploy that targets `ZY22K6BSJM` (USB)
and a bench that targets `10.0.0.17:5555` (WiFi) both end up using the
same node name.
Falls back to sanitizing the adb identifier itself when `getprop` fails
(e.g. unrooted device, missing executable, dead transport). The
fallback is the legacy behaviour, kept so a bench against a
pre-suffix-aware deploy still converges on *some* deterministic name.
Single adb shell call (~100–300 ms). Suitable for once-per-launch use
by the deployer and bench.
"""
@spec device_node_suffix(String.t()) :: String.t()
def device_node_suffix(adb_id) when is_binary(adb_id) do
case System.cmd("adb", ["-s", adb_id, "shell", "getprop", "ro.serialno"],
stderr_to_stdout: true
) do
{output, 0} ->
hardware_serial = String.trim(output)
if hardware_serial == "",
do: node_suffix_for(adb_id),
else: node_suffix_for(hardware_serial)
_ ->
node_suffix_for(adb_id)
end
end
defp run_adb(args) do
cmd = Enum.join(["adb" | args], " ")
case System.cmd("sh", ["-c", "timeout 8 #{cmd}"], stderr_to_stdout: true) do
{output, 0} -> {:ok, output}
{_output, 124} -> {:error, "adb timed out"}
{output, _} -> {:error, output}
end
end
end