Packages
mob_dev
0.6.22
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/otp_asset_bundle.ex
defmodule MobDev.OtpAssetBundle do
@moduledoc """
Builds the `assets/otp.zip` that release-mode Android Mob apps extract on
first launch.
Mirrors the elixir-desktop example-app pattern (their `Bridge.kt:unpackZip()`)
but with Mob-specific stripping inherited from the iOS release pass — drops
unused OTP libs and standalone executables to shave bundle size and avoid
shipping anything Mob never actually executes.
Called from `mix mob.release --android` (Workstream 3). Kept as a separate
module so the strip rules + zip layout are testable without spinning up
the full release pipeline.
## Layout produced
otp.zip
├── erts-VSN/... (BEAM emulator support — minus bin/)
├── lib/elixir-VSN/...
├── lib/logger-VSN/...
├── lib/eex-VSN/...
├── lib/<other-otp-libs-the-app-uses>/...
└── releases/...
The `MobBridge.extractOtpIfNeeded()` Kotlin function unzips this into
`<filesDir>/otp/` on first launch, keyed by `PackageInfo.lastUpdateTime`
so app updates trigger a re-extract.
## What gets stripped
Three categories, mirroring the iOS strip pass in `MobDev.Release`:
1. **Whole OTP libs the framework doesn't use** — `megaco`, `runtime_tools`,
`wx`, `observer`, `debugger`, etc. These are dead weight in a Mob app.
2. **Standalone executables** — `priv/bin/*` and `erts-*/bin/*`. The few
that Mob actually executes (`erl_child_setup`, `inet_gethost`, `epmd`)
are packaged separately in `jniLibs/<abi>/` as `lib<name>.so` so they
get the `apk_data_file` SELinux label that allows execve from
untrusted_app. The rest aren't reachable.
3. **Static archives (`.a`)** — already linked into the app's native lib
at build time. Shipping them inside the runtime tree is pure waste.
`.so` files inside OTP libs (e.g. `priv/lib/asn1.so`) are KEPT in the zip —
they're loaded by the BEAM at runtime via `dlopen()`, which works fine from
app data dir on Android (only `execve()` is blocked).
"""
@stripped_lib_prefixes ~w(
megaco runtime_tools erl_interface os_mon wx et eunit
observer debugger diameter edoc tools snmp dialyzer
syntax_tools parsetools xmerl reltool inets ftp tftp
)
@doc """
Builds the OTP asset zip from `source_otp_tree` into `target_zip_path`.
Returns `{:ok, %{zipped_files: count, original_size_kb: integer,
zip_size_kb: integer}}` on success, `{:error, reason}` on failure.
## Options
* `:strip_extra_prefixes` — additional OTP lib prefixes to drop on top of
the default list. Atoms or strings.
* `:keep_prefixes` — prefixes to KEEP even if in the default strip list.
Lets a specific app opt back into a stripped lib.
"""
@spec build(Path.t(), Path.t(), keyword) ::
{:ok, map} | {:error, term}
def build(source_otp_tree, target_zip_path, opts \\ []) do
with :ok <- check_source(source_otp_tree),
{:ok, staging} <- stage_and_strip(source_otp_tree, opts),
{:ok, info} <- zip_staging(staging, target_zip_path) do
File.rm_rf!(staging)
original_kb = du_kb(source_otp_tree)
{:ok, Map.put(info, :original_size_kb, original_kb)}
end
end
@doc """
Returns the list of OTP lib name prefixes that are stripped by default.
Public so tests can assert the policy without re-importing the module-private list.
"""
@spec default_stripped_prefixes() :: [String.t()]
def default_stripped_prefixes, do: @stripped_lib_prefixes
defp check_source(path) do
cond do
not File.dir?(path) ->
{:error, "OTP source tree not a directory: #{path}"}
not erts_in?(path) ->
{:error, "no erts-*/ directory under #{path} — not an OTP runtime tree?"}
true ->
:ok
end
end
defp erts_in?(path) do
case File.ls(path) do
{:ok, entries} -> Enum.any?(entries, &String.starts_with?(&1, "erts-"))
_ -> false
end
end
defp stage_and_strip(source, opts) do
staging = Path.join(System.tmp_dir!(), "mob_otp_zip_#{:erlang.unique_integer([:positive])}")
File.rm_rf!(staging)
File.mkdir_p!(staging)
case System.cmd("cp", ["-R", source <> "/.", staging], stderr_to_stdout: true) do
{_, 0} ->
# slim: false ships the OTP tree untouched. Required for apps that run
# arbitrary user code at runtime (e.g. an embedded Livebook host doing
# Mix.install) — we can't know which OTP libs (inets, ssl, xmerl,
# runtime_tools, …) a user's deps will need, so stripping any is unsafe.
if Keyword.get(opts, :slim, true) do
prefixes = compute_strip_set(opts)
strip_otp_libs(staging, prefixes)
strip_standalone_execs(staging)
strip_static_archives(staging)
strip_source_and_headers(staging)
strip_beam_chunks(staging)
end
{:ok, staging}
{out, _} ->
File.rm_rf!(staging)
{:error, "copy failed: #{out}"}
end
end
# Drop src/ and include/ from every lib. .erl source and .hrl headers
# are needed at compile time, not runtime. Saves ~16 MB on a typical
# OTP tree. Same logic as iOS release.ex's strip pass.
defp strip_source_and_headers(staging) do
Path.wildcard(Path.join(staging, "lib/*/src")) |> Enum.each(&File.rm_rf!/1)
Path.wildcard(Path.join(staging, "lib/*/include")) |> Enum.each(&File.rm_rf!/1)
:ok
end
# Strip optional chunks (Dbgi/Docs/etc.) from every shipped .beam.
# Same as `mix release --strip-beams` and the iOS release pass.
# Drops ~30% per .beam file. The host's `erl` and the bundled OTP are
# the same major version, so beam_lib:strip_release/1 is binary-safe.
#
# We're tolerant of strip failures: a stage tree from a test fixture
# may contain placeholder "fake beam" files that aren't valid BEAMs.
# In production those don't exist; the strip succeeds and shrinks
# the bundle. In tests, a failed strip just leaves the file untouched
# (which is the same behaviour as not running the step at all).
defp strip_beam_chunks(staging) do
{_, _status} =
System.cmd(
"erl",
[
"-noinput",
"-boot",
"start_clean",
"-eval",
~s|catch beam_lib:strip_release("#{staging}"), erlang:halt(0).|
],
stderr_to_stdout: true
)
:ok
end
defp compute_strip_set(opts) do
extra = opts |> Keyword.get(:strip_extra_prefixes, []) |> Enum.map(&to_string/1)
keep = opts |> Keyword.get(:keep_prefixes, []) |> Enum.map(&to_string/1)
(@stripped_lib_prefixes ++ extra) -- keep
end
defp strip_otp_libs(staging, prefixes) do
lib_dir = Path.join(staging, "lib")
case File.ls(lib_dir) do
{:ok, entries} ->
for entry <- entries,
Enum.any?(prefixes, &String.starts_with?(entry, &1 <> "-")) do
File.rm_rf!(Path.join(lib_dir, entry))
end
_ ->
:ok
end
end
defp strip_standalone_execs(staging) do
Path.wildcard(Path.join(staging, "lib/*/priv/bin/*"))
|> Enum.each(&File.rm_rf!/1)
Path.wildcard(Path.join(staging, "erts-*/bin/*"))
|> Enum.each(&File.rm_rf!/1)
end
defp strip_static_archives(staging) do
{_, 0} =
System.cmd("find", [staging, "-type", "f", "-name", "*.a", "-delete"],
stderr_to_stdout: true
)
:ok
end
defp zip_staging(staging, target_zip_path) do
target_zip_path = Path.expand(target_zip_path)
File.mkdir_p!(Path.dirname(target_zip_path))
File.rm(target_zip_path)
case System.cmd("zip", ["-9rq", target_zip_path, "."], cd: staging, stderr_to_stdout: true) do
{_, 0} ->
zipped = count_files(staging)
zip_size_kb = div(File.stat!(target_zip_path).size, 1024)
{:ok, %{zipped_files: zipped, zip_size_kb: zip_size_kb}}
{out, code} ->
{:error, "zip failed (#{code}): #{out}"}
end
end
defp count_files(dir) do
{out, 0} = System.cmd("find", [dir, "-type", "f"], stderr_to_stdout: true)
out |> String.split("\n", trim: true) |> length()
end
defp du_kb(path) do
case System.cmd("du", ["-sk", path], stderr_to_stdout: true) do
{out, 0} ->
out |> String.split() |> List.first() |> String.to_integer()
_ ->
0
end
end
end