Mob ships NIFs statically linked into the main app binary. This is non-negotiable on mobile:

  • iOS App Store rejects bundled .dylib files outright. A dlopen'd NIF can't pass review.
  • Android RTLD_LOCAL hides the parent process's enif_* symbols from a child library loaded by System.loadLibrary or dlopen. A NIF that looks for enif_make_atom at load time doesn't find it.

Both platforms point at the same answer: link the NIF's init function into the main binary alongside libbeam.a, and register it in a static NIF table so load_nif/2 resolves to the embedded code instead of opening a shared library.

mix mob.add_nif <name> is the single entry point. The four backends (c, rustler, zigler, elixir-only) all produce the same shape of artifact for the linker — a lib<name>.a (or <name>.o, for C) exporting <name>_nif_init. What differs is the language you write the NIF body in and the toolchain that produces the archive.

This guide is the contract per backend: how each upstream library normally works, and what Mob changes (or leaves alone) to make on-device static linking work. The aim is that a user — or an agent acting on their behalf — never has to read the build code to answer "what does Mob do with my Rust crate?"

For app-level integration of Python specifically (wheel handling, asset extraction, the host-dev fallback), see python_embedding.md — this guide covers only the NIF-layer story.


Anatomy of a static NIF in Mob

Every backend ends up at the same three artifacts in the same three places. The differences below are about how each gets generated, not what it is at link time.

ArtifactWhere it livesWho writes it
Elixir stub modulelib/<app>/nifs/<name>.exmob.add_nif scaffolds; you fill in function signatures
Native sourcec_src/<name>.c, native/<name>/src/lib.rs, or ~Z block in the stubdepends on backend
Static archivelib<name>.a (cross-compiled per arch)mob_dev invokes the backend's toolchain

The link-time dispatch table — priv/generated/driver_tab_ios.zig and priv/generated/driver_tab_android.zig — declares <name>_nif_init as extern fn and adds it to erts_static_nif_tab[], which the BEAM consults instead of dlopen when the Elixir stub calls :erlang.load_nif/2. The table is regenerated from mob.exs's :static_nifs list by mix mob.regen_driver_tab (which mob.add_nif composes automatically, so you rarely call it directly).

MobDev.StaticNifs is the schema reference for the manifest entries.


C

How erl_nif normally works

A C NIF is a .c file that includes <erl_nif.h>, defines a few functions matching the ErlNifFunc table, and ends with the ERL_NIF_INIT macro. The Erlang VM compiles it as a shared library, the Elixir module's :erlang.load_nif/2 dlopens that library, and the BEAM looks up the init symbol — which is hardcoded as plain nif_init in dynamic mode.

Upstream reference: the erl_nif man page and the User's Guide tutorial.

How Mob handles it

Mob compiles the same .c source as a regular .o file and links it straight into the app binary alongside libbeam.a. Two compile-time flags switch <erl_nif.h> from "dlopen mode" into "static mode":

  • -DSTATIC_ERLANG_NIF — selects the static-link dispatch path inside erl_nif.h. Without it the ERL_NIF_INIT macro emits the dynamic nif_init symbol, which collides across multiple NIFs.
  • -DSTATIC_ERLANG_NIF_LIBNAME=<name> — overrides the init symbol to <name>_nif_init. The static table declares it by that exact name, so they have to match. Without the override, the macro mangles to Elixir.<...>_nif_init, which isn't a valid C identifier and fails to compile.

That's all. The C code itself is identical to a portable NIF — no Mob-specific includes, no special prologue. You can prototype a NIF against any vanilla BEAM via mix compile then drop it into Mob unchanged.

Scaffold:

mix mob.add_nif audio_engine --type c

Drops c_src/audio_engine.c with the macro pre-wired, generates the Elixir stub, appends %{module: :audio_engine, archs: [:all]} to mob.exs's :static_nifs, and re-runs mob.regen_driver_tab.


Rust via Rustler

How Rustler normally works

A standard Rustler project has a Cargo crate (typically at native/<crate>/) declared with crate-type = ["cdylib"]. Cargo builds a .so containing #[rustler::nif]-annotated functions registered via the rustler::init!(...) macro. The Elixir side calls use Rustler, otp_app: :app, crate: "name", which:

  1. Invokes Cargo at compile time
  2. Copies the produced .so to priv/native/<crate>.so
  3. Wires :erlang.load_nif/2 to that path so the BEAM dlopens it

Upstream reference: the Rustler crate and the Rustler GitHub README.

How Mob handles it

Three differences from the standard flow. None of them touch the Rust source code.

1. Dual crate-type. Mob's scaffolded Cargo.toml has:

crate-type = ["staticlib", "cdylib"]

The cdylib keeps host-dev's mix compile working — same dlopen-the-.so path Rustler users know. The staticlib is what mob_dev's cross-compile actually consumes: cargo rustc --crate-type staticlib --target <arch> produces a lib<name>.a that's linked into the main app binary on-device.

2. Symbol convention requires rustler 0.37+. Rustler 0.37 changed the static-NIF init symbol to derive from CARGO_CRATE_NAME as <crate>_nif_init. Earlier versions hardcoded plain nif_init, which would collide if you had multiple Rust NIFs in one app (linker errors on duplicate symbols). Mob's driver_tab declares each NIF by <crate>_nif_init, so the pin to 0.37 is load-bearing — don't silently downgrade it.

3. Android dlsym workaround (transient). Rustler 0.37's nif_filler uses dlopen(NULL) to locate enif_* symbols at NIF init. On Bionic that handle resolves to a namespace which doesn't include the app's own .so siblings, even when they're marked RTLD_GLOBAL. Every NIF init panics with undefined symbol: enif_priv_data.

The scaffolded Cargo.toml carries a [patch.crates-io] block pointing at GenericJam/rustler:genericjam-android-rtld-default, which patches the Android branch to do dladdr + dlopen(self, RTLD_NOLOAD) for an explicit self-handle. iOS/macOS/Linux paths in the fork are unchanged. Tracker: mob#7. Drop the patch block (and bump the version pin) once upstream rustler merges the fix.

What stays standard

Everything in the Rust source. You can:

  • Put as many .rs files in native/<name>/src/ as you want, organized via standard mod foo; / mod bar; declarations.
  • Add any Cargo dependency to [dependencies]serde, tokio, bytes, anything. Cargo handles resolution and linking.
  • Write unit tests with cargo test and run them on the host like any other crate.
  • Use Rustler's full surface — Term, Atom, Encoder/Decoder, ResourceArc, NifTuple, etc. — without modification.

Preferred shape: one Rust crate, many NIFs

If you have several pieces of Rust functionality, the recommended shape is one Rustler crate per app, with multiple #[rustler::nif] functions inside it, registered by a single rustler::init!(...) call. This is what Rustler is designed around — dynamic loading of one shared library is the well-supported path, and our static-link pipeline inherits the same single-<crate>_nif_init symbol model.

// native/my_app/src/lib.rs
#[rustler::nif] fn audio_decode(bytes: Binary) -> NifResult<...> { ... }
#[rustler::nif] fn audio_encode(samples: Vec<f32>) -> NifResult<...> { ... }
#[rustler::nif] fn image_resize(buf: Binary, w: u32, h: u32) -> NifResult<...> { ... }

rustler::init!("Elixir.MyApp.Native", [audio_decode, audio_encode, image_resize]);
# Elixir-side wrappers can still live in topical modules:
defmodule MyApp.Audio do
  defdelegate decode(bytes), to: MyApp.Native, as: :audio_decode
  defdelegate encode(samples), to: MyApp.Native, as: :audio_encode
end

Why this is preferred (paraphrasing the upstream Rustler maintainer filmor): Rustler's design assumes one shared library loaded via load_nif. Static linking is something we layer on top with a recompiled BEAM (see libbeam.a) and Rustler 0.37's per-crate symbol mangling — the narrower you stay to "one crate, one init", the more upstream fixes and updates apply to you cleanly.

Internal organisation inside the single crate is unrestricted — mod audio;, mod image;, separate sub-modules with their own sub-deps in [dependencies], anything Cargo allows.

Multiple Rust crates per app (escape hatch)

If you genuinely need multiple separate Rustler crates in one app (e.g., crates whose Cargo dependency trees would conflict, or where you want each Elixir-side module to own its own :erlang.load_nif/2), Mob supports it: each mob.exs :static_nifs entry whose name matches a native/<name>/Cargo.toml is cross-compiled to its own lib<name>.a and linked. nif_combo ships three NIFs (greet_c + greet_rust + greet_zig) in one app as a working example.

Caveats compared to the single-crate preferred shape:

  • Each crate brings its own copy of any shared dependency unless you set up a Cargo workspace (which the scaffold doesn't generate but Cargo discovers transparently if you add native/Cargo.toml).
  • Symbol collisions are avoided only because Rustler 0.37+ mangles nif_init to <crate>_nif_init per CARGO_CRATE_NAME — silently downgrading rustler will break the build at link time.
  • Bug reports against rustler upstream are more likely to bounce ("we don't support that configuration") since this is outside the well-trodden dynamic-load path. The escape valves we ship (the GenericJam rustler fork for Android, the <crate>_nif_init driver_tab generator) are ours to maintain.

Scaffold a new crate:

mix mob.add_nif audio_engine --type rustler

If you're scaffolding a second Rustler-backed NIF, prefer adding the new functions to your existing crate over running mob.add_nif again.

Bringing in an existing Rust crate

mob.add_nif is for scaffolding a new NIF from scratch. If you already have a Rust crate written elsewhere — your own work, a multi-crate project authored against vanilla Rustler, anything that produces NIFs the standard way — Mob doesn't auto-import it, but the manual hookup is short. The four steps below are the whole list.

1. Drop the crate(s) into native/<name>/. Each crate gets its own directory with its own Cargo.toml and src/ tree. Mob doesn't care about Rust-internal structure — files, submodules, build.rs, bench targets, external deps, subdirectory module trees — that's all cargo's business. Mob compiles each crate via:

cargo rustc --release --target <arch> --crate-type staticlib \
            --manifest-path native/<name>/Cargo.toml

Whatever cargo can build, Mob can ship.

2. Per Cargo.toml: add staticlib to crate-type. A standard Rustler crate has:

[lib]
crate-type = ["cdylib"]

Mob needs:

[lib]
crate-type = ["staticlib", "cdylib"]

The cdylib keeps host-dev (mix compile on Mac/Linux) working through Rustler's normal dlopen path. The staticlib is what Mob's cross-compile consumes for the on-device link. One-line edit per crate.

3. Per Cargo.toml: add the Android dlsym patch. Until upstream rustler ships the dladdr+dlopen(NOLOAD) fix (see "When the upstream lands its own fix" below), each crate's Cargo.toml needs:

[patch.crates-io]
rustler = { git = "https://github.com/GenericJam/rustler.git",
            branch = "genericjam-android-rtld-default" }

Without it, NIF init panics on Android with undefined symbol: enif_priv_data. iOS/macOS/Linux are unaffected.

4. Register each crate in mob.exs. Open mob.exs and add one entry per crate to :static_nifs:

config :mob_dev,
  static_nifs: [
    %{module: :dsp_utils, archs: [:all]},
    %{module: :phy_modem, archs: [:all]},
    %{module: :melpe,     archs: [:all]}
  ]

The module: atom matches the directory name under native/ and the [lib] name = "..." in that Cargo.toml. Then run:

mix mob.regen_driver_tab

which rewrites priv/generated/driver_tab_{ios,android}.zig to declare and dispatch each new init function. mob.add_nif calls this for you; when bringing in crates by hand, run it explicitly.

The Elixir-side stubs (lib/<app>/nifs/<name>.ex) are also up to you to write. The pattern is:

defmodule MyApp.Nifs.PhyModem do
  use Rustler, otp_app: :my_app, crate: "phy_modem"

  def modulate(_input), do: :erlang.nif_error(:nif_not_loaded)
  # … one stub per #[rustler::nif] fn in the crate …
end

That's the full list. No further wiring is needed. mix mob.deploy --native cross-compiles every registered crate, links each lib<name>.a into the app binary, and :erlang.load_nif/2 resolves to the static dispatch table at startup.

Where to run mob commands from. Mob resolves native/<name>/ relative to the current working directory. For a standalone project that's the project root. For an umbrella project, run mob commands from the child app directory (the one whose apps/<app>/ contains mob.exs + ios/ + android/ + native/), not from the umbrella root. There is no umbrella-aware app selection (yet); running from the wrong directory silently finds no native/ entries and emits no NIFs.

One-time toolchain prerequisites — same as any cross-compiled Rust project, not Mob-specific:

rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-linux-android armv7-linux-androideabi

mix mob.doctor verifies these are installed and flags missing ones.

Caveat for external Cargo deps. Pure-Rust dependencies (rustfft, serde, tokio, etc.) cross-compile cleanly to all four Mob targets and need no special handling. Crates that pull in C via build.rs or bindgen may need the corresponding C library available for the target — that's upstream's concern, same as in any non-Mob cross-compile. If cargo rustc --target aarch64-linux-android fails for a transitive C dep, that's not a Mob issue; check the dep's own cross-compile instructions.


Zig via Zigler

How Zigler normally works

Zigler lets you embed Zig directly in an Elixir module via the ~Z sigil, compiles it through Zig's build system at mix compile time, and exposes each pub fn as a NIF function on the module. Standard usage produces a dynamically loaded .so — same dlopen-at-load model as Rustler's default.

Upstream reference: the Zigler hex docs and the Zigler GitHub README.

How Mob handles it

Mob uses a fork of Zigler pinned via the scaffolded mix.exs:

{:zigler, github: "GenericJam/zigler", branch: "zig-016-port"}

Two reasons the fork exists, both invisible to Zig source code:

1. macOS 26 (Sequoia/Tahoe) compatibility. Upstream Zigler pins Zig 0.15.x, whose bundled compiler_rt references __availability_version_check and friends that aren't present in the macOS 26 SDK. Compiles fail with a cascade of POSIX symbol-undefined errors on developer machines that have upgraded past the SDK boundary. The fork ports priv/beam/ to Zig 0.16.0, which works against the macOS 26 SDK.

2. Static-NIF init symbol collision. Zigler 0.16's emitted NIF init function was a bare nif_init (same name Rustler ≤0.36 used). When you statically link a Zig NIF alongside any other NIF in mob's driver_tab, the linker merges them into one symbol and silently corrupts per-module dispatch. The fork honors a -Dnif_init_alias=<name>_nif_init flag so each Zig NIF gets a unique exported init symbol, matching what driver_tab declares.

Once upstream Zigler ships Zig 0.16 support natively and accepts a per-NIF init alias, the fork dissolves. Track upstream issue #578 and PR #579.

The mob.add_nif --type zigler scaffold also runs mix zig.get afterward so Zigler's executable_path lookup finds the cached Zig 0.16.0 before falling through to PATH (which on most mob developer machines points at Zig 0.17-dev — wrong stdlib for the port).

What stays standard

The Zig source itself. Any pub fn becomes a NIF; Zigler's type-mapping table works as documented upstream; resource types, beam.send, etc. all behave identically to non-Mob Zigler projects.

Scaffold:

mix mob.add_nif audio_engine --type zigler

Python via Pythonx

Python is a different beast from C/Rust/Zig — it's not a single NIF crate you compile, it's a whole interpreter that ships inside the app. The static-link mechanics still apply (the Pythonx NIF is statically linked, just like any other Rust NIF), but the Python runtime + standard library + C extensions need to come from somewhere, and there's no upstream that ships one binary distribution covering iOS and Android.

How Pythonx normally works

Pythonx embeds CPython into the BEAM via a NIF (written in Rust, using PyO3). On a developer machine, it fetches CPython through uv on first run, installs it under a project-local cache, and dlopens libpython from there. Pythonx.eval/2 runs Python code in that in-process interpreter.

Upstream reference: Pythonx hex docs and the Pythonx GitHub README.

How Mob handles it

The Pythonx NIF itself is treated like any other Rust NIF — cross-compiled to libpythonx.a, linked into the main binary, registered in driver_tab. Same static-link story as Rustler above.

The Python runtime is the part that differs. There's no uv install on iOS or Android (no shell, no PATH, sandboxed filesystem), and Pythonx's normal fetch flow can't run on-device. Mob ships a pre-built CPython distribution for each platform, bundled into the app artifact and extracted on first launch.

Both platforms target Python 3.13 so user code is portable. The sources differ because no single upstream ships both:

iOS — BeeWare's Python-Apple-support.

  • Version pinned to 3.13-b13 (BeeWare's release tag — Python 3.13 plus their b13 build revision).
  • Distribution shape: an Python.xcframework containing the arch slices for iOS device + iOS simulator, plus the stdlib and standard C extensions (_ssl, _ctypes, _hashlib, etc.).
  • Tarball URL pattern: https://github.com/beeware/Python-Apple-support/releases/download/3.13-b13/Python-3.13-iOS-support.b13.tar.gz
  • Implementation: MobDev.PythonAppleSupport in mob_dev. Cached at ~/.mob/cache/python-apple-support-<version>/.

Android — Chaquopy's target distribution.

  • Version pinned to 3.13.9-0 (Chaquopy's <python>.<patch>-<chaquopy-rev> versioning — Python 3.13.9 plus Chaquopy revision 0).
  • Distribution shape: per-ABI zips (arm64-v8a + x86_64) containing libpython3.13.so plus libcrypto_python.so, libssl_python.so, libsqlite3_python.so, the lib-dynload C extensions, and a separate stdlib zip.
  • Maven Central URL pattern: https://repo1.maven.org/maven2/com/chaquo/python/target/...
  • License: Apache 2.0 (as of 2025).
  • Implementation: MobDev.PythonAndroidSupport in mob_dev. Cached at ~/.mob/cache/python-android-support-<version>/.

Why two sources? BeeWare's Python-Android-support (the natural sibling of Python-Apple-support) hasn't shipped a release since Python 3.10. Chaquopy is currently the only actively-maintained source of pre-built CPython binaries for Android 3.11+. We use it for the binaries only — Chaquopy's Java↔Python bridge is bypassed entirely; Pythonx talks to libpython3.13.so through the same FFI contract on both platforms.

What this means in practice:

  • Your Python code runs against CPython 3.13 on both platforms. The stdlib is BeeWare's pure-Python copy on iOS and Chaquopy's pure- Python copy on Android — both come straight from python.org's 3.13 source tree, so they're functionally identical for stdlib surface.
  • Standard C extensions (_ssl, socket, _hashlib, …) are present on both. Build flags differ slightly between BeeWare and Chaquopy, so a determined user could find a corner case where e.g. SSL cipher suites differ — but for nearly all code, the platforms are interchangeable.
  • Patch versions can drift mildly. Today iOS is on Python 3.13.x (BeeWare b13's underlying CPython tag) and Android is on Python 3.13.9 (Chaquopy's pin). Both move together in lockstep with our manual end-to-end validation when either upstream cuts a new release.
  • Third-party wheels are out of scope. See python_embedding.md for the "build your own wheel" guidance per platform.

What stays standard

Pythonx itself is unchanged. Pythonx.eval/2, Pythonx.encode/1, Pythonx.decode/1, the Pythonx.Object resource — all behave identically to a host-dev project. Your Python code never sees that the interpreter came from a different upstream.

Scaffold:

mix mob.enable pythonx     # not `mob.add_nif` — Pythonx is an enable, not a generic NIF

For the app-integration story (when Pythonx initializes, where extracted files live, how to ship third-party wheels, host-dev fallback), see python_embedding.md.


Multiple NIFs per app, generally

mob.exs's :static_nifs is a list. Each entry follows the schema:

%{module: :nif_name, archs: [:all]}        # link on all targets
%{module: :nif_name, archs: [:ios]}        # link only on iOS targets
%{module: :nif_name, archs: [:android_arm64]}  # narrow to one Android ABI

See MobDev.StaticNifs for the full schema, arch atoms, and the per-arch _nif_init symbol-name mapping. The order in the list determines link order, which matters if NIFs have inter-symbol dependencies (rare) but is otherwise cosmetic.

mob.add_nif always appends with archs: [:all] and assumes you'll narrow that manually if needed. Hand-editing mob.exs and re-running mix mob.regen_driver_tab is the supported way to adjust arch guards after the fact.


Nx backends on mobile

Nx applications often ask "which backend should I use on phone?" Three real options, picked by what your app actually needs:

BackendiOS deviceiOS simAndroid arm64Android arm32Enable with
Nx.BinaryBackend (pure Elixir)nothing — default
NxEigen (Eigen C++, CPU)mix mob.enable nxeigen
EMLX (Apple MLX, CPU + Metal GPU)mix mob.enable mlx
EXLA (Google XLA, JIT)not viable on mobile — see below

Nx.BinaryBackend

Pure Elixir. Works everywhere with no setup. Slow for real numerics work, but if your app is mostly stdlib ops or small tensors, it's the zero-friction option.

NxEigen — the CPU choice that works on both platforms

NxEigen is a CPU Nx backend over the Eigen C++ template library. Eigen is header-only, vectorised via SSE/NEON, and portable — clang with any Darwin or Android NDK target will compile it. For Android this is the only real numerics path (EMLX is Apple-only); for iOS without a Metal GPU available it's a fine CPU peer to EMLX.

Run mix mob.enable nxeigen in your app and follow the printed next steps. Mob handles the cross-compile by treating NxEigen's NIF as a C++ static-NIF entry — the build pipeline cross-compiles a libnx_eigen.a per target arch (arm64-ios, arm64-android, armv7a-android, etc.) and statically links it into the BEAM the same way it does the framework's own NIFs. No per-target prebuilt downloads; the source is two .cpp files + Eigen headers, compiled once per arch.

FFT support uses Eigen's bundled kissfft (header-only); a FFTW variant can be substituted later if you need higher throughput.

EMLX — Metal GPU on iOS, CPU on iOS sim

EMLX is the Elixir wrapper around Apple's MLX framework. iOS only — Android isn't possible (MLX is hard-tied to Metal + the Apple BLAS). Run mix mob.enable mlx and follow the printed next steps. The first cross-build downloads ~5 MB compressed (~30 MB on disk per arch) of pre-built libmlx.a + libemlx.a into ~/.mob/cache/.

By default EMLX uses Apple Accelerate (CPU) — fast, no GPU requirement. For Metal GPU on iOS device, opt in per backend reference:

Nx.global_default_backend({EMLX.Backend, device: :gpu})

Mob's deploy pipeline ships the precompiled mlx.metallib (Metal kernel library) inside the .app bundle so the GPU path works on device without runtime kernel compilation. iOS simulator doesn't have Metal; the :gpu backend silently falls back to CPU there.

Why not EXLA

EXLA sounds attractive — XLA's JIT compiler is genuinely fast — but isn't realistic on mobile today. Empirically: EXLA's BEAM modules ship and the Elixir layer loads, but EXLA.NIF is unloadable because:

  1. mix compile on the Mac builds libexla.dylib for macOS arm64 only — nothing for iOS or Android targets.
  2. Mob's cross-compile pipeline doesn't touch EXLA's NIF (it only drives entries declared in mob.exs :static_nifs or recognised Rustler/Zigler crates; EXLA isn't structured as either).
  3. Even if we did cross-compile EXLA's NIF, the runtime would then dlopen libxla.so from the xla Hex package — which only ships prebuilts for desktop/server (x86_64-linux, arm64-darwin, etc.). No arm64-android or arm64-ios prebuilt exists.

Getting EXLA to work on either mobile platform is two missing cross-compiles deep — building XLA itself for arm64-ios/android via Bazel (no published recipe, multi-day project, hundreds of MB output), then cross-compiling EXLA against that, then wiring both into the deploy pipeline. Not on the roadmap.

For ML model inference on mobile via Elixir, the natural future addition is TFLite (TensorFlow Lite), which is Google's blessed mobile path with native iOS + Android support. Would require writing TFLite-bindings as a NIF; not in mob today.


Inspecting what got linked

After mix mob.deploy --native finishes:

# iOS sim binary, list every static NIF init exported
nm -gU ios/MobApp.app/MobApp | grep _nif_init

# Android arm64 .so, same
llvm-readelf --dyn-syms android/app/build/intermediates/stripped_native_libs/debug/out/lib/arm64-v8a/lib<app>.so | grep _nif_init

Each :static_nifs entry should appear exactly once. A missing symbol means the link step didn't see the archive (check -Dproject_rust_libs in the zig invocation); a duplicate means two scaffolds collided (rename one).


When the upstream lands its own fix

The three transient items above will each have a clean exit:

BackendWorkaroundDrop when
Rustler[patch.crates-io] block in scaffold's Cargo.tomlupstream rustler ships the dladdr+dlopen(NOLOAD) fix for Android (tracker: mob#7)
Ziglergithub: "GenericJam/zigler", branch: "zig-016-port" pinupstream Zigler ships Zig 0.16 support AND honors a per-NIF nif_init_alias flag (tracker: upstream #578 / #579)
Python (Android)Chaquopy as the sourceBeeWare's Python-Android-support returns to active maintenance with a 3.11+ release

Until then these stay where they are — the scaffold writes them out loudly, with comments pointing at this guide.