MobDev.NativeBuild (mob_dev v0.7.17)

Copy Markdown View Source

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

Summary

Functions

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

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

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.

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

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.

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.

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.

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.

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.

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

Decides what to do for the exqlite install step.

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.

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

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

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.

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.

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

Returns the OTP directory for the given Android ABI string.

Returns the OTP directory for the given Android ABI string.

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

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

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.

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

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.

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.

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.

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.

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.

Functions

android_toolchain_available?(project_dir \\ File.cwd!())

@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(opts \\ [])

@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(cfg, opts \\ [])

@spec build_android_native(keyword(), keyword()) ::
  {:ok, %{required(String.t()) => 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(results, requested)

@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(entry)

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

copy_ios_safe_project_python_wheels(python_root, wheels_dir)

@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(arg1, slice, app_frameworks_dir)

@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(tflite_build, abi, project_root \\ nil)

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

@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?(project_dir \\ File.cwd!())

@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(team_id, bundle_id, aps_env \\ nil)

@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(build_dir)

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

install_exqlite_decision(vsn, ebin)

@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(target)

@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(cfg)

@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?()

@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(platforms, device_id)

@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(platforms, device_id, lister)

@spec narrow_platforms_for_device([atom()], String.t() | nil, (-> [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?(install_output, exit_code)

@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(arg1, arm64, arm32)

@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(arg1, arm64, arm32, x86_64)

@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!(otp_root, app, vsn)

@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!()

@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(bool, bundle)

@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?(project_dir \\ File.cwd!())

@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(project_dir \\ File.cwd!())

@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(by_runtime, device_id)

@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?(wheel_dir)

@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(label, fun)

@spec with_temp_build_dir(String.t(), (Path.t() -> 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!(path, content)

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