# `MobDev.NativeBuild`
[🔗](https://github.com/genericjam/mob_dev/blob/master/lib/mob_dev/native_build.ex#L1)

Builds native binaries (APK for Android, .app bundle for iOS simulator)
for the current Mob project.

Reads paths from `mob.exs` in the project root. If `mob.exs` is missing
or paths haven't been configured, prints instructions and exits.

OTP runtimes for Android and iOS are downloaded automatically from GitHub
and cached at `~/.mob/cache/` by `MobDev.OtpDownloader`.

## mob.exs keys

  * `:mob_dir`           — mob library repo (native C/ObjC/Swift source);
                           must be the `:mob` dependency's directory
  * `:elixir_lib`        — Elixir stdlib lib dir
  * `:project_swift_sources` — optional extra Swift sources compiled into
                           the iOS app module

# `android_toolchain_available?`

```elixir
@spec android_toolchain_available?(String.t()) :: boolean()
```

Returns true when the Android build toolchain looks usable from the given
project directory. Three signals must all be present:

  1. `adb` is on PATH (build needs it to install the APK after Gradle)
  2. `<project_dir>/android/local.properties` exists and sets `sdk.dir`
  3. The directory `sdk.dir` points at exists on disk

Returns false otherwise so the deploy can skip Android cleanly instead of
failing late inside Gradle. Pure of side effects.

# `build_all`

```elixir
@spec build_all(keyword()) :: boolean()
```

Builds native binaries for all platforms present in the project.
Runs Android Gradle build if `android/` dir exists.
Runs the Mix-driven iOS pipeline (delegating native compile + link
to `ios/build.zig` for sim, `ios/build_device.zig` for device) when
`ios/build.zig` exists. `devices:` supplies the frozen target snapshot used
by `mix mob.deploy`; older direct callers may still use `device:`.

# `build_android_native`

```elixir
@spec build_android_native(keyword(), keyword()) ::
  {:ok, %{required(String.t()) =&gt; Path.t()}} | {:error, String.t()}
```

Everything the Android build does before Gradle runs: fetch the OTP runtime
per ABI, stage the ERTS helpers into `jniLibs/`, compile the app's native
library (`jniLibs/<abi>/lib<app>.so`, with every activated plugin's JNI and
NIF code linked in) and merge the plugins' manifest, Gradle, Kotlin, resource
and font contributions into `android/`. Needs no device.

`mix mob.deploy --native` and `mix mob.release --android` both run it, so a
release links the same native code a dev build would instead of whatever
`.so` the last deploy left in `jniLibs/` (MOB-404).

Options:

  * `:abis` — the ABIs to build, all of which must succeed (the release
    passes Gradle's `abiFilters`, see `release_android_abis/1`). Without it,
    every ABI mob supports is built and one the app's `build.zig` predates
    is skipped with a warning (dev builds).

Returns `{:ok, %{abi => otp_dir}}` for the ABIs built.

# `build_outcome`

```elixir
@spec build_outcome([{:ok, String.t()} | {:error, String.t(), term()}], [atom()]) ::
  :ok | {:error, String.t()}
```

Whether a native build run succeeded, given what it produced and what the
user explicitly asked for.

`results` entries are `{:ok, label}` / `{:error, label, reason}` where label
is the display name ("Android", "iOS", "iOS (device)").

`requested` is the platforms required by an explicit `--android` / `--ios`
flag, broad target scope, or named `--device` under `--native` — NOT the
complete resolved platform list, which collapses "no flag given" into every
platform and would make an ordinary skip fatal.

The rule this exists for: `ok_count == length(results)` is `0 == 0` for a run
that built nothing, so `mix mob.deploy --android --native` with no `sdk.dir`
printed a warning, built nothing, and reported success. The same failure
occurred for `--native --device <android-id>` because the device-resolved
platform was not considered requested. A skip is fine when nobody asked for
that platform; it is a failure when they did.

# `classify_project_nif`

```elixir
@spec classify_project_nif(MobDev.StaticNifs.nif_entry()) ::
  {:c, Path.t()} | {:rust, Path.t()} | {:zig, atom()} | :elixir_only
```

# `copy_ios_safe_project_python_wheels`

```elixir
@spec copy_ios_safe_project_python_wheels(String.t(), String.t()) :: :ok
```

iOS-flavoured counterpart to `copy_project_python_wheels/1`. Same
`priv/python_wheels/` convention, same site-packages destination,
but skips any wheel directory containing a `.so` file at any depth.

Today's wheel set ships Android-built binaries (Chaquopy-compatible)
under names like `_cffi_backend.so` and `_rust.so` — no `"android"`
in the filename — so a name-based heuristic misses them. Until the
wheels directory holds platform-tagged subdirs (or an iOS-specific
source), treating "has any `.so`" as "Android-only, skip on iOS"
matches the current reality: pure-Python wheels (rns, lxmf,
pyserial, pycparser) are the only iOS-safe ones. RNS falls back to
its internal crypto provider when `cryptography` isn't importable,
so this is enough to bring the Reticulum stack up on iOS device
builds.

Public so the iOS-specific filter can be tested independently of
the rest of the bundle pipeline.

# `copy_tflite_frameworks_ios`

```elixir
@spec copy_tflite_frameworks_ios(nil | map(), String.t(), Path.t()) :: :ok
```

Copy the TFLite frameworks (Core + CoreML + Metal) into the iOS app's
`Frameworks/` dir so the .app bundle ships them. Called during iOS
app assembly when TFLite is enabled.

Same pattern as `Python.framework` embedding. Codesigning happens at
the app-bundle level — the frameworks just need to be present in the
bundle when the codesign step runs.

`slice` is either `"ios-arm64"` (device) or
`"ios-arm64_x86_64-simulator"` (sim).

No-op when `tflite_build` is nil.

# `copy_tflite_runtime_lib_android`

```elixir
@spec copy_tflite_runtime_lib_android(nil | map(), String.t(), Path.t() | nil) :: :ok
```

Copy the TFLite runtime library (`libtensorflowlite_jni.so`) into the
Android app's `jniLibs/<abi>/` so the APK packager includes it. Called
during the Android assemble step when TFLite is enabled.

`project_root` defaults to the current working directory — that's the
Mob-app project root in normal `mix mob.deploy` invocations. Tests
pass an explicit path to avoid cd'ing into a temp dir (which would
race other tests' parallel compilation).

No-op when `tflite_build` is nil (TFLite not enabled in this project).

# `detect_physical_ios`

```elixir
@spec detect_physical_ios() :: String.t() | nil
```

Returns the UDID of the sole connected physical iOS device, or nil.
When exactly one physical device is connected, it can be used automatically.
With zero or 2+ physical devices, returns nil.

# `emlx_in_project?`

```elixir
@spec emlx_in_project?(String.t()) :: boolean()
```

True when the current project has `:emlx` in its dependency tree.
Mirrors `pythonx_in_project?/1` — the trigger for downloading the MLX
bundle and adding `-Dmlx_static=true` to the iOS Zig build.

# `fallback_entitlements_plist`

```elixir
@spec fallback_entitlements_plist(String.t(), String.t(), String.t() | nil) ::
  String.t()
```

Generates the fallback entitlements plist that `build_device.sh` writes when
no `ios/*.entitlements` file is found in the project.

`aps_env` should be `"development"`, `"production"`, or `nil`.  When non-nil
the `aps-environment` key is included, allowing APNs push token registration
to succeed.  When nil the key is omitted (the historic default, suitable for
apps that do not use push notifications).

This function is public so it can be unit-tested independently of the shell
script that actually writes the file on device builds.

# `generate_erl_errno_compat_stub`

```elixir
@spec generate_erl_errno_compat_stub(Path.t()) :: :ok
```

# `install_exqlite_decision`

```elixir
@spec install_exqlite_decision(String.t() | nil, String.t()) ::
  :noop | :stale | {:install, String.t()}
```

Decides what to do for the exqlite install step.

  * `:noop` — no exqlite lock entry; project doesn't use it.
  * `:stale` — lock entry exists but the dep isn't compiled in
    `_build/dev/lib/exqlite/`. Common cause: `ecto_sqlite3` was once
    a dep, was removed, and the transitive `exqlite` lock entry
    stayed behind (mix.lock isn't auto-pruned). Returning `:stale`
    makes the caller skip cleanly instead of crashing on a
    missing-source `File.cp!`.
  * `{:install, vsn}` — version is locked and the `.app` file is
    present; safe to install.

Public so the stale-lock guard can be regression-tested without
setting up an end-to-end build.

# `ios_build_inputs_dir`

```elixir
@spec ios_build_inputs_dir(:ios_sim | :ios_device) :: Path.t()
```

The project's directory for the sources mob_dev generates for an iOS
`zig build` (`enif_keepalive.c`, `erl_errno_id_compat.c`,
`mob_plugin_bootstrap.swift`): `<Mix.Project.build_path/0>/mob_ios/<target>`,
created if missing.

zig keys its cache on these files' paths as well as their contents, and one
swiftc step compiles the bootstrap together with every Swift source, so a
path that changed each build recompiled all the Swift and relinked every
time. Kept per target and shared by every device of that target; write into
it with `write_build_input!/2` so concurrent builds never read a half-written
file.

Public for testing.

# `ios_bundle_id`

```elixir
@spec ios_bundle_id(keyword()) :: String.t() | nil
```

The bundle id every iOS build path stamps and signs with: `mob.exs`'s
`:ios_bundle_id` when set, else `:bundle_id`.

Single source of truth for the sim bundle, the device bundle, and code
signing — those three disagreeing is how a project ends up installed
under one id and addressed by another. Mirrors
`MobDev.Config.ios_bundle_id/0`, which is what the deploy/connect side
resolves (`cfg[:bundle_id]` is already `Config.bundle_id/0` by the time
`load_config/0` is done with it).

Public for testing.

# `ios_toolchain_available?`

```elixir
@spec ios_toolchain_available?() :: boolean()
```

Returns true when an iOS build is feasible: macOS host with `xcrun`
installed. Linux/Windows always returns false. Pure of side effects.

# `narrow_platforms_for_device`

```elixir
@spec narrow_platforms_for_device([atom()], String.t() | nil) :: [atom()]
```

When `--device <id>` is given, narrow `platforms` to just the platform
the device lives on. Drops Android when the id resolves to an iOS
device (sim or physical), drops iOS otherwise.

Public so `mix mob.deploy` can apply the same narrowing before calling
`MobDev.Deployer.deploy_all/1` — otherwise the deployer's per-platform
`filter_by_device_id` complains "No device matched" against the
irrelevant platform even though the build itself was correctly
targeted.

Returns `platforms` unchanged when `device_id` is nil.

# `narrow_platforms_for_device`

```elixir
@spec narrow_platforms_for_device([atom()], String.t() | nil, (-&gt; [MobDev.Device.t()])) ::
  [atom()]
```

Variant that takes an iOS-discovery function so tests (and other
callers that already have the device list in hand) can avoid the
network-bound `IOS.list_devices/0` LAN scan.

The lister is called at most once per invocation; both `ios_device?`
and the physical-UDID format fallback consume the same result.

# `needs_clean_reinstall?`

```elixir
@spec needs_clean_reinstall?(String.t(), integer()) :: boolean()
```

Decide whether an `adb install -r` result forces a clean (uninstall + install)
reinstall.

True when the in-place update was rejected — a non-zero exit or an
`INSTALL_FAILED_*` line (signature mismatch, version downgrade, etc.). A clean
reinstall wipes app data (on-device identity, screen stores), so the caller
only falls back to it when the in-place update genuinely cannot apply.

# `otp_dir_for_abi`

```elixir
@spec otp_dir_for_abi(String.t(), String.t(), String.t()) :: String.t()
```

Returns the OTP directory for the given Android ABI string.

# `otp_dir_for_abi`

```elixir
@spec otp_dir_for_abi(String.t(), String.t(), String.t(), String.t()) :: String.t()
```

Returns the OTP directory for the given Android ABI string.

# `prepare_otp_lib_dir!`

```elixir
@spec prepare_otp_lib_dir!(String.t(), String.t(), String.t()) :: String.t()
```

Prepare `otp_root/lib/<app>-<vsn>`, removing every OTHER version of `app`
first, and return the path.

Public so the stale-version sweep can be regression-tested without running an
end-to-end native build, matching `install_exqlite_decision/2`.

# `prepare_plugin_build_state!`

```elixir
@spec prepare_plugin_build_state!() :: :ok
```

Regenerates every file derived from the activated plugins and checks them,
before anything is compiled or bundled. Shared by `build_all/1` and the
release pipelines (`MobDev.ReleaseAndroid.build_aab/1`,
`MobDev.Release.build_ipa/1`), so a release never ships the plugin set of
whatever dev build last ran in the checkout (MOB-404).

# `python_apple_support_env`

```elixir
@spec python_apple_support_env(boolean(), String.t() | nil) :: [
  {String.t(), String.t()}
]
```

Returns the PYTHON_APPLE_SUPPORT env entry list when Pythonx is in the
project, otherwise `[]`. Kept public — `mob.release` and other release
paths still call into this when constructing distribution-mode envs.

# `pythonx_in_project?`

```elixir
@spec pythonx_in_project?(String.t()) :: boolean()
```

Returns true when the user's project has a built `:pythonx` dependency.

Detection is via `_build/dev/lib/pythonx/` rather than scanning `mix.exs`
so users get the same behavior whether they `mix mob.enable python` and
rely on the dep being added, or vendor pythonx some other way.

# `release_android_abis`

```elixir
@spec release_android_abis(Path.t()) :: {:ok, [String.t()]} | {:error, String.t()}
```

The ABIs `mix mob.release --android` must build: every entry of the app's
Gradle `abiFilters` (all ABIs mob supports when unset), since Gradle packages
`jniLibs/<abi>/lib<app>.so` for each of them and an ABI left unbuilt would
ship a stale library. Errors when a filter names an ABI mob can't build.

# `resolve_booted_udid`

```elixir
@spec resolve_booted_udid(map(), String.t() | nil) :: String.t() | nil
```

Given the JSON-decoded `xcrun simctl list devices booted -j` result
and an optional `device_id` (full UDID or any case-insensitive
prefix of one), return the matching booted simulator's full UDID
or nil.

When `device_id` is nil → first booted sim wins.
When `device_id` is a string → case-insensitive prefix match
against booted UDIDs. A full UDID matches itself; an 8-char
prefix matches the corresponding device. Public for testing —
JSON shape is the contract.

# `wheel_has_native_extension?`

```elixir
@spec wheel_has_native_extension?(String.t()) :: boolean()
```

True if `wheel_dir` contains at least one `.so` file at any depth.
Used by `copy_ios_safe_project_python_wheels/2` to detect
Android-only wheels.

# `with_temp_build_dir`

```elixir
@spec with_temp_build_dir(String.t(), (Path.t() -&gt; result)) :: result when result: var
```

Runs `fun` with a fresh output directory,
`<System.tmp_dir!/0>/mob_<label>_<os pid>_<n>`, and deletes it when `fun`
returns, raises, throws or exits. Returns what `fun` returns.

The iOS builds bundle the `.app` and write codesign scratch here; none of it
is read after install. The OS pid keeps concurrent deploys apart:
`System.unique_integer/1` restarts low in every VM, so two `mix mob.deploy`
runs could otherwise pick the same name and the first to finish would
delete the other's bundle. The sources zig compiles go in
`ios_build_inputs_dir/1` instead, where their paths stay stable. See
`decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md` (MOB-313).

Public for testing.

# `write_build_input!`

```elixir
@spec write_build_input!(Path.t(), iodata()) :: :ok
```

Writes a generated build source. Leaves the file alone when it already holds
`content`; otherwise writes a sibling temp file and renames it over `path`,
so a concurrent build sees the old file or the new one, never a partial one.

Public for testing.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
