Packages
mob_dev
0.6.21
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/release_android.ex
defmodule MobDev.ReleaseAndroid do
@moduledoc """
Builds the signed release AAB for a Mob Android app.
Called by `mix mob.release --android`. The pipeline:
1. Download the Android OTP runtime (arm64) if not already cached.
2. Copy the OTP tree to a temp staging dir and add:
- App + dep BEAMs (flattened into `{app_name}/`)
- App `priv/` → `{app_name}/priv/`
- exqlite BEAMs → `lib/exqlite-{vsn}/ebin/` (OTP lib structure needed
for `:code.lib_dir(:exqlite)` to resolve correctly at runtime)
3. Run `MobDev.OtpAssetBundle.build/2` — strips unused OTP libs and
optional BEAM chunks, then zips the tree to `assets/otp.zip`.
4. Run `./gradlew bundleRelease` — signs the AAB using the keystore
configured in `android/keystore.properties`.
`MobBridge.extractOtpIfNeeded()` (Kotlin) extracts `otp.zip` into
`<filesDir>/otp/` on first launch. Without this zip the app crashes
immediately — the BEAM has no runtime or application BEAMs to load.
"""
@app_assets "android/app/src/main/assets"
@doc """
Runs the full Android release pipeline and returns `{:ok, aab_path}` or
`{:error, reason}`.
"""
@spec build_aab(keyword()) :: {:ok, Path.t()} | {:error, String.t()}
def build_aab(opts \\ []) do
app_name = Mix.Project.config()[:app] |> to_string()
slim = Keyword.get(opts, :slim, true)
with :ok <- check_android_project(),
log("Ensuring Android OTP runtime..."),
{:ok, otp_arm64} <- MobDev.OtpDownloader.ensure_android("arm64-v8a"),
log("Staging OTP tree + app BEAMs..."),
{:ok, staging} <- stage_otp_tree(otp_arm64, app_name),
log("Building otp.zip (stripping unused OTP libs)..."),
{:ok, info} <- build_zip(staging, slim),
_ = File.rm_rf!(staging),
log(
" #{info.zipped_files} files, " <>
"#{div(info.original_size_kb, 1024)}MB → #{div(info.zip_size_kb, 1024)}MB"
),
log("Running ./gradlew bundleRelease..."),
{:ok, aab} <- gradle_bundle_release() do
{:ok, aab}
end
end
# ── Staging ──────────────────────────────────────────────────────────────────
defp stage_otp_tree(otp_dir, app_name) do
staging =
Path.join(System.tmp_dir!(), "mob_android_release_#{:erlang.unique_integer([:positive])}")
File.rm_rf!(staging)
case System.cmd("cp", ["-R", otp_dir <> "/.", staging], stderr_to_stdout: true) do
{_, 0} ->
add_app_beams!(staging, app_name)
add_app_priv!(staging, app_name)
add_exqlite!(staging)
# Only stub :crypto when the OTP runtime genuinely lacks the
# OpenSSL NIF. When crypto.a is present (the Android CMakeLists.txt
# statically links crypto.a + libcrypto.a and registers
# crypto_nif_init in the driver table), the real :crypto works —
# stubbing it replaces crypto.beam with one whose supports/1
# returns [], making :ssl.versions/0 raise and breaking every
# HTTPS request (TLS handshake never starts).
if real_crypto_available?(otp_dir) do
log(" real crypto.a present — keeping OpenSSL crypto (no stub)")
else
patch_crypto_deps!(staging)
add_crypto_stub!(staging, app_name)
end
{:ok, staging}
{out, _} ->
{:error, "Failed to copy OTP tree: #{out}"}
end
end
# True when the OTP runtime ships the real OpenSSL crypto NIF static
# archive (crypto.a). The Android native build links it into the app
# .so, so the BEAM has working :crypto and must not get the stub.
# Public for testing.
@doc false
@spec real_crypto_available?(Path.t()) :: boolean()
def real_crypto_available?(otp_dir) do
Path.wildcard(Path.join(otp_dir, "erts-*/lib/crypto.a")) != []
end
# Flatten all runtime BEAMs (app + deps) into {staging}/{app_name}/.
# This mirrors how the deployer stages BEAMs for adb push:
# all dirs are copied into one flat directory on the -pa code path.
defp add_app_beams!(staging, app_name) do
beam_dirs = collect_beam_dirs()
dest = Path.join(staging, app_name)
File.mkdir_p!(dest)
Enum.each(beam_dirs, fn dir ->
System.cmd("cp", ["-r", "#{Path.expand(dir)}/.", dest], stderr_to_stdout: true)
end)
end
defp add_app_priv!(staging, app_name) do
local_priv = Path.join(File.cwd!(), "priv")
if File.dir?(local_priv) do
dest = Path.join([staging, app_name, "priv"])
File.rm_rf!(dest)
System.cmd("cp", ["-R", local_priv, dest], stderr_to_stdout: true)
end
:ok
end
# exqlite BEAMs must live at lib/exqlite-VSN/ebin/ in the OTP root so that
# :code.lib_dir(:exqlite) resolves correctly. mob_beam.c creates the
# sqlite3_nif.so symlink at runtime from the APK's nativeLibraryDir.
defp add_exqlite!(staging) do
with vsn when is_binary(vsn) <- exqlite_version(),
[ebin | _] <- Path.wildcard("_build/dev/lib/exqlite/ebin") do
lib_dir = Path.join(staging, "lib/exqlite-#{vsn}")
File.mkdir_p!(Path.join(lib_dir, "ebin"))
File.mkdir_p!(Path.join(lib_dir, "priv"))
System.cmd("cp", ["-r", "#{Path.expand(ebin)}/.", Path.join(lib_dir, "ebin")],
stderr_to_stdout: true
)
else
_ -> :ok
end
end
# Same runtime BEAM collection as deployer.collect_beam_dirs/0: app + deps +
# eex (Elixir stdlib, not in _build/) + ssl (Thousand Island dep, not in OTP tree).
defp collect_beam_dirs do
app_dirs = MobDev.HotPush.runtime_beam_dirs()
eex_ebin = Path.join(to_string(:code.lib_dir(:eex)), "ebin")
eex = if File.dir?(eex_ebin), do: [eex_ebin], else: []
ssl_ebin = Path.join(to_string(:code.lib_dir(:ssl)), "ebin")
ssl = if File.dir?(ssl_ebin), do: [ssl_ebin], else: []
app_dirs ++ eex ++ ssl
end
defp exqlite_version, do: MobDev.AppFile.dep_version(:exqlite)
# The Android OTP release lacks :crypto (no cross-compiled OpenSSL NIF).
# Many deps (ecto, phoenix_pubsub, plug_crypto, …) declare it as a required
# application dependency, which causes Application.ensure_all_started to fail
# at startup. We patch every .app file in the staging tree to remove :crypto
# from the applications list, then inject a minimal crypto.beam stub
# (priv/android/crypto.erl) that implements strong_rand_bytes/1 via :rand.
defp patch_crypto_deps!(staging) do
staging
|> Path.join("**/*.app")
|> Path.wildcard()
|> Enum.each(&remove_crypto_from_app_file/1)
end
defp remove_crypto_from_app_file(path) do
case :file.consult(String.to_charlist(path)) do
{:ok, [{:application, name, props}]} ->
apps = Keyword.get(props, :applications, [])
new_apps = Enum.reject(apps, &(&1 == :crypto))
if new_apps != apps do
new_props = Keyword.put(props, :applications, new_apps)
term_str = :io_lib.format("~p.~n", [{:application, name, new_props}])
File.write!(path, IO.chardata_to_string(term_str))
end
_ ->
:ok
end
end
defp add_crypto_stub!(staging, app_name) do
stub_src =
:code.priv_dir(:mob_dev)
|> to_string()
|> Path.join("android/crypto.erl")
dest_dir = Path.join(staging, app_name)
tmp_dir = Path.join(System.tmp_dir!(), "mob_crypto_stub")
File.mkdir_p!(tmp_dir)
case System.cmd("erlc", ["-o", tmp_dir, stub_src], stderr_to_stdout: true) do
{_, 0} ->
beam = Path.join(tmp_dir, "crypto.beam")
if File.exists?(beam) do
File.cp!(beam, Path.join(dest_dir, "crypto.beam"))
File.rm!(beam)
end
# Write crypto.app so the OTP application controller can load and
# start the :crypto application. Without this file, ensure_all_started
# fails with {error, {crypto, {"no such file or directory", "crypto.app"}}}
# even when crypto.beam is present — the app controller requires the
# .app spec to register the application before starting it.
# No {mod, ...} entry: starting :crypto just marks it as started,
# with no NIF initialization (our stub uses :rand instead).
crypto_app = """
{application,crypto,[
{description,"CRYPTO stub for Android"},
{vsn,"5.5"},
{modules,[crypto]},
{registered,[]},
{applications,[kernel,stdlib]},
{env,[]}
]}.
"""
File.write!(Path.join(dest_dir, "crypto.app"), crypto_app)
{out, _} ->
Mix.shell().info(" warning: could not compile crypto stub: #{out}")
end
end
# ── otp.zip ──────────────────────────────────────────────────────────────────
defp build_zip(staging, slim) do
zip_path = Path.expand(Path.join(@app_assets, "otp.zip"))
File.mkdir_p!(Path.dirname(zip_path))
MobDev.OtpAssetBundle.build(staging, zip_path, slim: slim)
end
# ── Gradle ───────────────────────────────────────────────────────────────────
defp gradle_bundle_release do
gradlew = Path.expand("android/gradlew")
aab = Path.expand("android/app/build/outputs/bundle/release/app-release.aab")
case System.cmd("bash", [gradlew, "bundleRelease", "--no-daemon"],
cd: Path.expand("android"),
stderr_to_stdout: true,
into: IO.stream()
) do
{_, 0} ->
if File.exists?(aab) do
{:ok, aab}
else
{:error, "Gradle succeeded but AAB not found at #{aab}"}
end
{_, rc} ->
{:error, "Gradle bundleRelease failed (exit #{rc}) — see output above."}
end
end
# ── Helpers ──────────────────────────────────────────────────────────────────
defp check_android_project do
cond do
not File.dir?("android") ->
{:error, "No android/ directory — run from the root of a Mob Android project."}
not File.exists?("android/gradlew") ->
{:error, "android/gradlew not found."}
true ->
:ok
end
end
defp log(msg), do: Mix.shell().info(" #{msg}")
end