mix mob.deploy (mob_dev v0.7.0)

Copy Markdown View Source

Compiles the project then pushes BEAM files to all connected Android devices and iOS simulators.

Modes

Fast deploy (default) — push BEAMs + restart. Use this for day-to-day Elixir code changes. Requires the native app already installed on device.

mix mob.deploy

Full deploy — build native binary + install APK/app + push BEAMs. Use this the first time, or after changes to native C/Java/Swift code.

mix mob.deploy --native

Options

  • --native — build native binaries before pushing BEAMs

  • --no-restart — push BEAMs but don't restart the app

  • -d, --device <id> — target a specific device; use mix mob.devices to find IDs

  • --dist-port <N> — pin the BEAM dist listen port (default: auto-allocated per

                        device, `9100 + index`). Use to resolve EPMD collisions when
                        multiple sims/emulators are running the same app concurrently
                        and the auto-allocated ports aren't what you want.
  • --node-suffix <S> — append _<S> to the BEAM node name (default: auto-derived

                        from device serial on Android, SIMULATOR_UDID on iOS sim). Use
                        for scripted scenarios where you need a specific naming scheme.
  • --schedulers <N> — set BEAM scheduler count (saved to mob.exs)

  • --beam-flags "<flags>" — arbitrary BEAM flags string (saved to mob.exs)

  • --json — machine-readable result on stdout; progress goes to

                        stderr, so `mix mob.deploy --json | jq` gets one document
  • --slim — strip OTP source/debug for size measurement on

                          a real device. OFF by default for dev iteration
                          (the strip pass adds ~5-10s per build); use this
                          to verify a slim build runs before
                          `mix mob.republish` round-trips through TestFlight.
                          The strip set is controlled by `MobDev.OtpAudit.Slim`;
                          per-app overrides live in `mob.exs`:
    
                              config :mob_dev,
                                slim: [
                                  drop_libs: ["my_unused_dep"],
                                  keep_libs: ["mnesia"],
                                  audit: true,                       # opt in
                                  # Single capture (a starting point):
                                  trace_json: "priv/mob_trace.json",
                                  # OR multiple captures unioned —
                                  # much safer for production
                                  # stripping. A lib is trace-
                                  # strippable only if NONE of the
                                  # captures observed any of its
                                  # modules.
                                  trace_jsons: [
                                    "priv/boot.json",
                                    "priv/ui.json",
                                    "priv/auth.json"
                                  ]
                                ]
    
                          With `audit: true`, the slim pass runs
                          `MobDev.OtpAudit` against the bundle and
                          expands the strip set with foreign apps
                          + (when a trace is supplied) the
                          trace-augmented strip set. Trace JSON
                          comes from `mix mob.trace_otp --json`.

BEAM scheduler tuning

The default native build uses 1:1 (single scheduler) for battery efficiency. Override for the current deploy and all future deploys until changed:

# Pin to 2 schedulers
mix mob.deploy --schedulers 2

# Let BEAM auto-detect — one scheduler per logical core
mix mob.deploy --schedulers 0

# Arbitrary flags (replaces --schedulers)
mix mob.deploy --beam-flags "-S 4:4 -A 4"

The chosen value is written to mob.exs under beam_flags: and reused on subsequent mix mob.deploy runs that don't pass either flag. The flags are written alongside the BEAMs as a mob_beam_flags file that the native launcher reads at startup — no APK/app rebuild required.

Under the hood

A fast deploy is equivalent to:

mix deps.get                                     # only with --native
mix compile

# Android
adb push _build/prod/lib/*/ebin/*.beam /data/data/<pkg>/files/lib/*/ebin/
adb shell am force-stop <package>               # restart

# iOS simulator
xcrun simctl spawn <udid> cp <beam_files> <app_bundle>/

When Erlang distribution is already reachable (app running, node connected), mix mob.deploy skips adb push and hot-pushes via RPC instead — equivalent to calling nl(Module) in IEx for every changed module:

:rpc.call(node, :code, :load_binary, [Module, path, beam_binary])

With --native, it also runs the platform build before pushing BEAMs:

# Android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk

# iOS simulator
xcodebuild -scheme <app> -destination 'platform=iOS Simulator,...' build
xcrun simctl install booted <app>.app

Exit status

Every targeted device is attempted and the full summary printed, then the task exits non-zero if any device landed in the Failed on N device(s) bucket — including a partial success where other devices deployed fine.

Devices under Skipped on N device(s) (app not installed for that platform) do not fail the run — unless you named that platform. A skip means "this device is not a target for this app", which is ordinary when it is a phone that happens to be attached, and a failure when the run asked for it:

  • mix mob.deploy with an unrelated phone attached — exit 0.
  • mix mob.deploy --ios where every iOS device was skipped — exit 1.
  • mix mob.deploy --ios where one simulator deployed and a stale one was skipped — exit 0. A partial success is a success; the rule is per platform, not per device.
  • mix mob.deploy --device X that reached X and deployed nothing — exit 1.
  • mix mob.deploy --device NOPE matching no device — exit 1.
  • mix mob.deploy --android --native that built the APK with no device attached — exit 0. The artifact is what was asked for.

--native fails the run when a platform you named built nothing at all, which is what a missing sdk.dir in android/local.properties produces.

Summary

Functions

The Mix.raise message for a finished deploy, or nil when the run should exit 0.

As failure_message/3, but knowing which platforms were explicitly asked for.

Build the per-deploy summary lines from the three device buckets.

The error for options the task does not accept.

Rewrite --flag value to --flag=value when the value starts with a dash.

The machine-readable result of a finished deploy.

The message for a run that named a device and did not find it, or nil.

The platforms the user explicitly asked for, from the raw flags.

Functions

failure_message(deployed, failed, skipped)

@spec failure_message([MobDev.Device.t()], [MobDev.Device.t()], [MobDev.Device.t()]) ::
  String.t() | nil

The Mix.raise message for a finished deploy, or nil when the run should exit 0.

A deploy that printed "Failed on N device(s)" used to still exit 0, so CI and wrapper scripts read a failed deploy as a success.

Only failed (a real error during push) is fatal. skipped is not: it means "app not installed for that platform", the expected outcome of e.g. building --ios with an Android phone also plugged in — the same distinction format_summary/4 renders.

Partial success is still a failure. Every targeted device is still attempted and reported before this runs, so the operator can see which ones got the BEAMs; a script has no way to notice one device missed out if the status code says everything is fine.

failure_message(deployed, failed, skipped, requested)

@spec failure_message([MobDev.Device.t()], [MobDev.Device.t()], [MobDev.Device.t()], [
  atom()
]) ::
  String.t() | nil

As failure_message/3, but knowing which platforms were explicitly asked for.

A skipped device is normally not a failure — it means "this device is not a target for this app", the expected outcome of an Android phone being attached during a default run. It IS a failure when the run named that platform: a mix mob.deploy --android that skips every Android device asked for something and got nothing, and must not report success.

Pass [] for requested and every skip is incidental, which is the failure_message/3 behaviour.

failure_message(deployed, failed, skipped, requested, native_built?)

@spec failure_message(
  [MobDev.Device.t()],
  [MobDev.Device.t()],
  [MobDev.Device.t()],
  [atom()],
  boolean()
) :: String.t() | nil

As failure_message/4, but knowing whether a native build succeeded.

A --native run that built the artifact and found no device to push it to did its main job. Failing it would break "build the APK now, attach the phone after", which used to exit 0.

format_summary(deployed, failed, skipped, opts \\ [])

@spec format_summary(
  [MobDev.Device.t()],
  [MobDev.Device.t()],
  [MobDev.Device.t()],
  keyword()
) :: [
  String.t()
]

Build the per-deploy summary lines from the three device buckets.

Returns an iolist of strings (one per line) that the task prints verbatim. Public so the report shape can be pinned against fixture device lists — keeps "Failed on N" from regressing back into counting skipped-because-not-installed devices.

Opts:

  • :restart — boolean; controls the post-deploy IEx hint line

invalid_options_message(invalid)

@spec invalid_options_message([{String.t(), String.t() | nil}]) :: String.t()

The error for options the task does not accept.

Names them, because the failure this replaces was silent: the flag was dropped and the deploy proceeded as if it had never been passed.

join_dashed_values(args)

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

Rewrite --flag value to --flag=value when the value starts with a dash.

OptionParser will not consume a dash-prefixed argument as a :string value, so --beam-flags "-S 4:4 -A 4" — the spelling this repo prints in seven places, including the README and both battery-bench workflows — parsed as two unknown options. Under the old lenient parsing the value was silently dropped and the deploy carried on with whatever mob.exs held; under strict parsing it became a hard failure that named a valid option as unknown.

BEAM flags essentially all start with a dash, so this is not an edge case: it is the documented invocation.

json_result(deployed, failed, skipped, message)

@spec json_result(
  [MobDev.Device.t()],
  [MobDev.Device.t()],
  [MobDev.Device.t()],
  String.t() | nil
) ::
  map()

The machine-readable result of a finished deploy.

Exists because an agent driving mix mob.deploy otherwise has to infer the outcome from coloured prose, and the exit code alone does not say which target missed out. outcome mirrors the exit status: "ok" when the task returns 0, "error" when it raises.

missing_device_message(device_id, arg2, arg3, skipped)

@spec missing_device_message(
  String.t() | nil,
  [MobDev.Device.t()],
  [MobDev.Device.t()],
  [
    MobDev.Device.t()
  ]
) :: String.t() | nil

The message for a run that named a device and did not find it, or nil.

mix mob.deploy --device NOPE printed "No devices found." and exited 0. The device filter matches nothing, every bucket comes back empty, and a run that shipped to a device you named by id is indistinguishable from one that shipped nowhere.

Only fires when a device was named: with no --device, an empty run is the ordinary "nothing is plugged in" case and stays non-fatal.

requested_platforms(opts)

@spec requested_platforms(keyword()) :: [:android | :ios]

The platforms the user explicitly asked for, from the raw flags.

Deliberately NOT resolve_platforms/1, which collapses "no flag given" into every platform with a scaffold. That distinction is the whole point: a device skipped during a default run is incidental (a phone that happens to be attached), while one skipped during --android is a request that went unserved. Returns [] when no platform flag was given.