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 withxcrun simctl listappsfirst to distinguish skip-vs-uninstall.iOS physical device (devicectl) —
xcrun devicectl device uninstall app --device <udid> <bundle>. Exit 0 → uninstalled.ContainerLookupErrorDomainin stderr →:skipped(app not installed on this device).
Summary
Types
An uninstall plan — list of (device, [bundle_id]) pairs.
Built from plan/1; executed by execute_plan/1.
Why plan/1 couldn't build a matrix.
Functions
Bucket results into {uninstalled, failed, skipped}.
Mirrors MobDev.Deployer.categorize_results/1.
Execute a plan/0 against the connected devices. Returns
{uninstalled, failed, skipped} lists of result/0 maps.
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.
Parse adb uninstall output into an outcome.
Parse xcrun devicectl device uninstall app output into an outcome.
Parse adb shell pm list packages <prefix> output into a list of
package names. Returns names without the package: prefix, sorted
for deterministic ordering.
Build the (devices × apps) plan without executing it.
Build a human-readable preview of the uninstall matrix.
Pick the target device set from all connected devices given the
user-supplied opts.
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.
Types
@type outcome() :: :uninstalled | :skipped | :error
@type plan() :: [{MobDev.Device.t(), [String.t()]}]
An uninstall plan — list of (device, [bundle_id]) pairs.
Built from plan/1; executed by execute_plan/1.
@type plan_error() ::
:no_devices
| :ambiguous_devices
| :no_matching_devices
| :no_dev_devices
| :no_physical_devices
Why plan/1 couldn't build a matrix.
@type result() :: %{ device: MobDev.Device.t(), bundle_id: String.t(), outcome: outcome(), reason: String.t() | nil }
Functions
Bucket results into {uninstalled, failed, skipped}.
Mirrors MobDev.Deployer.categorize_results/1.
Execute a plan/0 against the connected devices. Returns
{uninstalled, failed, skipped} lists of result/0 maps.
@spec filter_devices_by_id([MobDev.Device.t()], [String.t()]) :: [MobDev.Device.t()]
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 interpret_adb_uninstall(String.t(), non_neg_integer()) :: {outcome(), String.t() | nil}
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_devicectl_uninstall(String.t(), non_neg_integer()) :: {outcome(), String.t() | nil}
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 →
:errorwith 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.
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 plan(keyword()) :: {:ok, plan()} | {:error, plan_error(), map()}
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--deviceor--all-devicesflag.{:error, :no_matching_devices, %{requested: [...]}}— the user passed--deviceIDs but none matched.
@spec preview_lines([{MobDev.Device.t(), [String.t()]}]) :: [String.t()]
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 select_devices([MobDev.Device.t()], [String.t()], keyword()) :: [ MobDev.Device.t() ]
Pick the target device set from all connected devices given the
user-supplied opts.
Precedence:
:device_idsnon-empty — match by id, regardless of type (the user typed the id, that's explicit consent).- Both
:all_devicesand:all_physical— every connected device. :all_devicesonly — emulators/simulators (NEVER physical). The destructive sweep is safe by default; touching a physical device requires explicit--all-physicalor--device <id>.:all_physicalonly — physical devices only.- 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.
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.