Current section

Files

Jump to
mob_dev lib mob_dev uninstaller.ex
Raw

lib/mob_dev/uninstaller.ex

defmodule MobDev.Uninstaller do
@moduledoc """
Uninstall a Mob app (or every Mob-prefixed app) from one or more
connected devices.
The user-facing surface is `mix mob.uninstall`. This module owns the
matrix math + per-platform uninstall mechanics so the Mix task stays
thin and the testable invariants live here.
## Scope dimensions
Two orthogonal axes:
* **Devices** — one auto-detected (when exactly one is connected),
a list of named devices (`--device foo --device bar`), or every
connected device (`--all-devices`).
* **Apps** — the current project's bundle id by default, every
installed package matching the user's `bundle_prefix`
(`--all-apps`), or an explicit override (`--bundle-id`).
The (devices × apps) matrix gets flattened into per-pair uninstall
attempts and the results bucket into `{uninstalled, failed, skipped}`
— same shape as `MobDev.Deployer.deploy_all/1` so the report
rendering can borrow the same idiom.
## Per-platform mechanics
* **Android (adb)** — `adb -s <serial> uninstall <pkg>`.
stderr "Unknown package" → `:skipped` (not installed). All
other non-zero exits → `:error`.
* **iOS simulator (xcrun simctl)** — `xcrun simctl uninstall
<udid> <bundle>`. simctl returns exit 0 whether the app was
installed or not, so we probe with `xcrun simctl listapps`
first to distinguish skip-vs-uninstall.
* **iOS physical device (devicectl)** — `xcrun devicectl device
uninstall app --device <udid> <bundle>`. Exit 0 → uninstalled.
`ContainerLookupErrorDomain` in stderr → `:skipped`
(app not installed on this device).
"""
alias MobDev.Discovery.{Android, IOS}
alias MobDev.Device
@type outcome :: :uninstalled | :skipped | :error
@type result :: %{
device: Device.t(),
bundle_id: String.t(),
outcome: outcome(),
reason: String.t() | nil
}
@typedoc """
An uninstall plan — list of (device, [bundle_id]) pairs.
Built from `plan/1`; executed by `execute_plan/1`.
"""
@type plan :: [{Device.t(), [String.t()]}]
@typedoc "Why `plan/1` couldn't build a matrix."
@type plan_error ::
:no_devices
| :ambiguous_devices
| :no_matching_devices
| :no_dev_devices
| :no_physical_devices
@doc """
Build the (devices × apps) plan without executing it.
The Mix task uses this to render a "what's about to happen" preview
and prompt for confirmation before any destructive work runs.
Recognized opts: same as `uninstall_all/1`.
Returns:
* `{:ok, plan}` — plan ready to execute. Each pair has at least
one device and one bundle id.
* `{:error, :no_devices, %{detected: 0}}` — no connected devices.
* `{:error, :ambiguous_devices, %{detected: N}}` — multiple
devices connected, no `--device` or `--all-devices` flag.
* `{:error, :no_matching_devices, %{requested: [...]}}` — the
user passed `--device` IDs but none matched.
"""
@spec plan(keyword()) :: {:ok, plan()} | {:error, plan_error(), map()}
def plan(opts \\ []) do
all = list_all_devices(opts)
device_ids = opts[:device_ids] || []
selected = select_devices(all, device_ids, opts)
cond do
all == [] ->
{:error, :no_devices, %{detected: 0}}
device_ids != [] and selected == [] ->
{:error, :no_matching_devices, %{requested: device_ids, detected: length(all)}}
selected == [] ->
# Could mean: --all-devices was set but no emulators/sims
# connected (only physical), OR no flags + multiple devices
# connected, OR no flags + zero non-physical devices. Use
# the available counts to pick the right error.
ambiguous_or_only_physical_error(all, opts)
true ->
{:ok, build_plan(selected, opts)}
end
end
@doc """
Pick the target device set from `all` connected devices given the
user-supplied opts.
Precedence:
1. `:device_ids` non-empty — match by id, regardless of type
(the user typed the id, that's explicit consent).
2. Both `:all_devices` and `:all_physical` — every connected
device.
3. `:all_devices` only — emulators/simulators (NEVER physical).
The destructive sweep is safe by default; touching a physical
device requires explicit `--all-physical` or `--device <id>`.
4. `:all_physical` only — physical devices only.
5. Auto-detect: exactly one NON-physical device → target it.
Physical devices are never the auto-target.
Public for testing — the precedence ladder is the safety contract.
"""
@spec select_devices([Device.t()], [String.t()], keyword()) :: [Device.t()]
def select_devices(all, device_ids, opts) do
# Coerce to explicit booleans — opts[:foo] is nil when the flag
# wasn't passed, and `nil and X` crashes under Elixir 1.20.
all_devices? = Keyword.get(opts, :all_devices, false) == true
all_physical? = Keyword.get(opts, :all_physical, false) == true
cond do
device_ids != [] ->
filter_devices_by_id(all, device_ids)
all_devices? and all_physical? ->
all
all_devices? ->
Enum.reject(all, &Device.physical?/1)
all_physical? ->
Enum.filter(all, &Device.physical?/1)
true ->
non_physical = Enum.reject(all, &Device.physical?/1)
if length(non_physical) == 1, do: non_physical, else: []
end
end
defp ambiguous_or_only_physical_error(all, opts) do
physical = Enum.filter(all, &Device.physical?/1)
non_physical = Enum.reject(all, &Device.physical?/1)
# Same boolean-coercion guard as in select_devices/3.
all_devices? = Keyword.get(opts, :all_devices, false) == true
all_physical? = Keyword.get(opts, :all_physical, false) == true
cond do
all_devices? and non_physical == [] ->
# --all-devices targets non-physical; user has only phones.
{:error, :no_dev_devices,
%{
physical_count: length(physical),
hint:
"Only physical devices connected. `--all-devices` targets " <>
"emulators/simulators only — use `--all-physical` to also " <>
"uninstall on physical devices, or `--device <id>` to " <>
"target one explicitly."
}}
all_physical? and physical == [] ->
{:error, :no_physical_devices, %{detected: length(all)}}
true ->
# No flags + ambiguous (>1 non-physical) OR no non-physical at all.
{:error, :ambiguous_devices,
%{
detected: length(all),
non_physical: length(non_physical),
physical: length(physical)
}}
end
end
@doc """
Execute a `plan/0` against the connected devices. Returns
`{uninstalled, failed, skipped}` lists of `result/0` maps.
"""
@spec execute_plan(plan()) :: {[result()], [result()], [result()]}
def execute_plan(plan) do
plan
|> Enum.flat_map(fn {device, bundles} ->
Enum.map(bundles, &uninstall_one(device, &1))
end)
|> categorize_results()
end
@doc """
Top-level orchestration: discover devices, resolve target apps,
run the uninstall matrix. Equivalent to `plan/1 |> execute_plan/1`
but raises on the plan-error cases instead of returning a tuple —
useful for tests that just want results without going through the
Mix-task layer.
Recognized opts: see `plan/1`.
"""
@spec uninstall_all(keyword()) :: {[result()], [result()], [result()]}
def uninstall_all(opts \\ []) do
case plan(opts) do
{:ok, p} ->
execute_plan(p)
{:error, reason, ctx} ->
raise ArgumentError,
"Uninstaller.uninstall_all/1 — plan failed (#{reason}): #{inspect(ctx)}"
end
end
defp build_plan(devices, opts) do
bundle_prefix = opts[:bundle_prefix] || MobDev.Config.bundle_prefix()
Enum.map(devices, fn d ->
{d, resolve_apps_for_device(d, opts, bundle_prefix)}
end)
end
# ── Pure helpers (the testable surface) ────────────────────────────────
@doc """
Bucket `results` into `{uninstalled, failed, skipped}`.
Mirrors `MobDev.Deployer.categorize_results/1`.
"""
@spec categorize_results([result()]) :: {[result()], [result()], [result()]}
def categorize_results(results) do
uninstalled = for %{outcome: :uninstalled} = r <- results, do: r
failed = for %{outcome: :error} = r <- results, do: r
skipped = for %{outcome: :skipped} = r <- results, do: r
{uninstalled, failed, skipped}
end
@doc """
Filter a list of `%Device{}` by `device_ids`. When `device_ids` is
empty, return all. When non-empty, match by serial OR name (case-
sensitive prefix on serial, exact on either name). Devices not
found are dropped silently — the caller is expected to validate
user intent.
Used by `resolve_devices/1` when the user passes `--device foo`.
"""
@spec filter_devices_by_id([Device.t()], [String.t()]) :: [Device.t()]
def filter_devices_by_id(devices, []), do: devices
def filter_devices_by_id(devices, ids) do
Enum.filter(devices, fn d ->
Enum.any?(ids, &Device.match_id?(d, &1))
end)
end
@doc """
Parse `adb uninstall` output into an outcome.
Adb's reporting is informal:
* `"Success"` (sometimes followed by other lines) → `:uninstalled`
* stderr contains `"Failure"` with `"DELETE_FAILED_INTERNAL_ERROR"`
and `"Unknown package"` → `:skipped` (not installed)
* any other non-zero exit → `:error`
`output` is the combined stdout+stderr; `exit_code` is the process
exit status. Returns `{outcome, reason}` where `reason` is a
short user-facing string (or nil for the success case).
"""
@spec interpret_adb_uninstall(String.t(), non_neg_integer()) ::
{outcome(), String.t() | nil}
def interpret_adb_uninstall(output, exit_code) do
cond do
exit_code == 0 and String.contains?(output, "Success") ->
{:uninstalled, nil}
String.contains?(output, "Unknown package") ->
{:skipped, "not installed"}
exit_code != 0 ->
{:error, String.trim(output)}
true ->
# adb sometimes returns exit 0 with no Success marker — be
# conservative and treat unclear output as an error rather
# than claiming success.
{:error, String.trim(output)}
end
end
@doc """
Parse `xcrun devicectl device uninstall app` output into an outcome.
devicectl is more structured than adb but its error reporting still
varies by Xcode version. Patterns:
* exit 0 → app actually uninstalled (or wasn't there — devicectl
doesn't always distinguish; check the listapps probe if
precision matters).
* `ContainerLookupErrorDomain` → bundle id not installed → `:skipped`.
* `MissingProfileError` / `NotPaired` → device-pairing problem,
real error.
* Anything else exit != 0 → `:error` with trimmed output.
Output is the combined stdout+stderr; exit_code is the process exit
status. Returns `{outcome, reason}`.
Public for regression-testing without a paired physical device.
"""
@spec interpret_devicectl_uninstall(String.t(), non_neg_integer()) ::
{outcome(), String.t() | nil}
def interpret_devicectl_uninstall(output, exit_code) do
cond do
exit_code == 0 ->
{:uninstalled, nil}
String.contains?(output, "ContainerLookupErrorDomain") or
String.contains?(output, "not installed") ->
{:skipped, "not installed"}
true ->
{:error, String.trim(output)}
end
end
@doc """
Parse `adb shell pm list packages <prefix>` output into a list of
package names. Returns names without the `package:` prefix, sorted
for deterministic ordering.
iex> MobDev.Uninstaller.parse_package_list("package:com.example.a\\npackage:com.example.b\\n")
["com.example.a", "com.example.b"]
iex> MobDev.Uninstaller.parse_package_list("")
[]
iex> MobDev.Uninstaller.parse_package_list("garbage\\n")
[]
"""
@spec parse_package_list(String.t()) :: [String.t()]
def parse_package_list(output) when is_binary(output) do
output
|> String.split("\n", trim: true)
|> Enum.flat_map(fn line ->
case String.split(String.trim(line), "package:", parts: 2) do
["", pkg] -> [pkg]
_ -> []
end
end)
|> Enum.sort()
end
@doc """
Build a human-readable preview of the uninstall matrix.
Used by the Mix task to show "what's about to happen" before the
destructive step. Lines are colored ANSI (faint/cyan/dim) but the
ANSI codes can be stripped for assertion-friendly tests.
"""
@spec preview_lines([{Device.t(), [String.t()]}]) :: [String.t()]
def preview_lines([]), do: ["(no apps to uninstall — nothing to do)"]
def preview_lines(pairs) do
header = "About to uninstall:"
rows =
Enum.flat_map(pairs, fn {device, bundles} ->
[
" #{IO.ANSI.cyan()}#{device.name || device.serial}#{IO.ANSI.reset()} (#{device.platform}):"
| Enum.map(bundles, &" - #{&1}")
]
end)
[header | rows]
end
# ── Device discovery ───────────────────────────────────────────────────
defp list_all_devices(opts) do
platforms = opts[:platforms] || [:android, :ios]
android =
if :android in platforms,
do: Android.list_devices() |> Enum.reject(&(&1.status == :unauthorized)),
else: []
ios = if :ios in platforms, do: IOS.list_devices(), else: []
android ++ ios
end
# ── App resolution per device ──────────────────────────────────────────
defp resolve_apps_for_device(device, opts, bundle_prefix) do
cond do
opts[:bundle_id] ->
[opts[:bundle_id]]
opts[:all_apps] ->
list_matching_packages(device, bundle_prefix)
true ->
[opts[:project_bundle_id] || MobDev.Config.bundle_id()]
end
end
defp list_matching_packages(%Device{platform: :android, serial: serial}, prefix) do
{output, _} =
System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", prefix],
stderr_to_stdout: true
)
parse_package_list(output)
end
defp list_matching_packages(%Device{platform: :ios, serial: udid}, prefix) do
# simctl returns a hashmap-ish plist of installed apps. Quick
# path: dump as JSON, walk top-level keys, filter by prefix.
case System.cmd("xcrun", ["simctl", "listapps", udid], stderr_to_stdout: true) do
{output, 0} -> simctl_listapps_with_prefix(output, prefix)
_ -> []
end
end
defp list_matching_packages(_device, _prefix), do: []
@doc false
@spec simctl_listapps_with_prefix(String.t(), String.t()) :: [String.t()]
def simctl_listapps_with_prefix(output, prefix) when is_binary(output) do
# simctl listapps output is roughly:
# "com.example.foo" = { ... };
# "com.example.bar" = { ... };
# Pull the quoted keys via regex; cheap, no plist parser needed
# for this use case.
Regex.scan(~r/"([^"]+)"\s*=\s*\{/, output)
|> Enum.map(fn [_, name] -> name end)
|> Enum.filter(&String.starts_with?(&1, prefix))
|> Enum.sort()
end
# ── Per-device uninstall ───────────────────────────────────────────────
defp uninstall_one(%Device{platform: :android, serial: serial} = d, bundle_id) do
{output, exit_code} =
System.cmd("adb", ["-s", serial, "uninstall", bundle_id], stderr_to_stdout: true)
{outcome, reason} = interpret_adb_uninstall(output, exit_code)
%{device: d, bundle_id: bundle_id, outcome: outcome, reason: reason}
end
defp uninstall_one(%Device{platform: :ios, type: :physical, serial: udid} = d, bundle_id) do
# iOS physical device: devicectl. Distinct from simctl —
# devicectl returns non-zero with "ContainerLookupErrorDomain"
# when the bundle id isn't installed on the device, vs simctl
# which returns 0 either way.
args = [
"devicectl",
"device",
"uninstall",
"app",
"--device",
udid,
bundle_id
]
{output, exit_code} = System.cmd("xcrun", args, stderr_to_stdout: true)
{outcome, reason} = interpret_devicectl_uninstall(output, exit_code)
%{device: d, bundle_id: bundle_id, outcome: outcome, reason: reason}
end
defp uninstall_one(%Device{platform: :ios, serial: udid} = d, bundle_id) do
# iOS simulators: simctl uninstall returns 0 either way. Probe
# the app-list first so the outcome distinguishes
# "actually-uninstalled" from "wasn't there to begin with".
case System.cmd("xcrun", ["simctl", "uninstall", udid, bundle_id], stderr_to_stdout: true) do
{_, 0} ->
# Was it installed? Check now that simctl has supposedly
# processed the uninstall. If simctl listapps still shows it,
# the uninstall didn't take.
if package_listed_on_sim?(udid, bundle_id) do
%{
device: d,
bundle_id: bundle_id,
outcome: :error,
reason: "still installed after uninstall"
}
else
%{device: d, bundle_id: bundle_id, outcome: :uninstalled, reason: nil}
end
{output, _} ->
case classify_simctl_error(output) do
:not_installed ->
%{device: d, bundle_id: bundle_id, outcome: :skipped, reason: "not installed"}
:error ->
%{device: d, bundle_id: bundle_id, outcome: :error, reason: String.trim(output)}
end
end
end
defp uninstall_one(%Device{platform: platform} = d, bundle_id) do
%{
device: d,
bundle_id: bundle_id,
outcome: :skipped,
reason:
"platform #{inspect(platform)} not supported by `mix mob.uninstall` (use Xcode/Android Studio)"
}
end
defp package_listed_on_sim?(udid, bundle_id) do
case System.cmd("xcrun", ["simctl", "listapps", udid], stderr_to_stdout: true) do
{output, 0} -> String.contains?(output, ~s("#{bundle_id}"))
_ -> false
end
end
@doc false
@spec classify_simctl_error(String.t()) :: :not_installed | :error
def classify_simctl_error(output) when is_binary(output) do
if String.contains?(output, "No such application") or
String.contains?(output, "not installed") do
:not_installed
else
:error
end
end
end