MobDev.Uninstaller (mob_dev v0.5.10)

Copy Markdown View Source

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).

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

outcome()

@type outcome() :: :uninstalled | :skipped | :error

plan()

@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.

plan_error()

@type plan_error() ::
  :no_devices
  | :ambiguous_devices
  | :no_matching_devices
  | :no_dev_devices
  | :no_physical_devices

Why plan/1 couldn't build a matrix.

result()

@type result() :: %{
  device: MobDev.Device.t(),
  bundle_id: String.t(),
  outcome: outcome(),
  reason: String.t() | nil
}

Functions

categorize_results(results)

@spec categorize_results([result()]) :: {[result()], [result()], [result()]}

Bucket results into {uninstalled, failed, skipped}. Mirrors MobDev.Deployer.categorize_results/1.

execute_plan(plan)

@spec execute_plan(plan()) :: {[result()], [result()], [result()]}

Execute a plan/0 against the connected devices. Returns {uninstalled, failed, skipped} lists of result/0 maps.

filter_devices_by_id(devices, ids)

@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.

interpret_adb_uninstall(output, exit_code)

@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).

interpret_devicectl_uninstall(output, exit_code)

@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 → :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.

parse_package_list(output)

@spec parse_package_list(String.t()) :: [String.t()]

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")
[]

plan(opts \\ [])

@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 --device or --all-devices flag.
  • {:error, :no_matching_devices, %{requested: [...]}} — the user passed --device IDs but none matched.

preview_lines(pairs)

@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.

select_devices(all, device_ids, opts)

@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:

  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.

uninstall_all(opts \\ [])

@spec uninstall_all(keyword()) :: {[result()], [result()], [result()]}

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.