Android wheels

PyJNIus can be published as a prebuilt PEP 738 Android wheel (android_* tags), so an Android packager can pip install it instead of compiling from source per app. The wheel is SDL-agnostic and Java-free: it resolves the JVM JNIEnv at runtime and ships no Java. In return, the host application must satisfy a small runtime contract (below).

Note

This describes the redistributable wheel. On desktop, PyJNIus still starts its own JVM and bundles the compiled NativeInvocationHandler.class as before; nothing here changes desktop behavior.

The runtime contract

The wheel does not create a JVM on Android; it attaches to the one the host process already has. A conforming host must provide:

  1. An in-process JVM discoverable at import. The JNIEnv is resolved at the first import jnius in this order:

    • SDL_GetAndroidJNIEnv from libSDL3.so (SDL3), else

    • SDL_AndroidGetJNIEnv from libSDL2.so (SDL2), else

    • JNI_GetCreatedJavaVMs from libnativehelper.so (SDL-independent, API 31+ only) + AttachCurrentThread.

    For an SDL host, its SDL library must be loaded before the first import jnius (a Kivy/SDL app loads SDL at startup, which captures the JavaVM via SDL’s own JNI_OnLoad). Each tier tries dlsym(RTLD_DEFAULT, ...) first, then dlopens the library by soname and resolves the symbol from that handle – a CPython extension imported via dlopen does not have the host’s System.loadLibrary‘d SDL in its default lookup scope. No DT_NEEDED on any libSDL/libnativehelper is created, so the wheel loads regardless of which (if any) SDL is present.

  2. The Java glue on the app’s dex classpath. PyJNIus’s Python-implements-a-Java-interface feature (PythonJavaClass/@java_method) needs the class org.jnius.NativeInvocationHandler. A .whl carries no .dex and a class in site-packages is not on ART’s classpath, so the wheel does not and cannot deliver it. The packager (bootstrap/build system) must compile and dex org.jnius.NativeInvocationHandler into the APK. Plain autoclass and method calls need no glue; only proxies do.

    Warning

    Matched-pair coupling. The wheel’s native invoke0 contract (bound at runtime via RegisterNatives) and the packager’s copy of NativeInvocationHandler.java must be updated together. Nothing enforces this once the glue is out of the wheel – a mismatch surfaces as a ClassNotFound/NoSuchMethod at first proxy creation. The canonical source lives in the PyJNIus repository at jnius/src/org/jnius/NativeInvocationHandler.java.

If no JVM/glue is found, the first import jnius raises a clear RuntimeError naming the missing getter, rather than a cryptic dlopen/symbol failure.

Building the wheel

Build on a Linux x86_64 (WSL2 counts) or macOS host with an Android SDK; cibuildwheel drives the NDK via sdkmanager. CI pins the cibuildwheel action in .github/workflows/android-wheels.yml; the selected Android Python bundles specify the NDK version. Android build settings live in [tool.cibuildwheel.android] in pyproject.toml; its before-build check rejects a compiler from an unexpected NDK version.

# 1. Android compiles a *pre-generated* jnius.c (Cython is build-only and not
#    needed on the target). config.pxi selects the android include branch.
printf "DEF JNIUS_PLATFORM = 'android'\n" > jnius/config.pxi
cython -3 jnius/jnius.pyx -o jnius/jnius.c

# 2. Build from a CLEAN tree. `python -m build --no-isolation` reuses
#    build/lib.android-*; a stale one from an earlier build re-injects the
#    Java payload and breaks the Java-free guarantee. A fresh checkout (CI) is
#    already clean.
rm -rf build

# 3. arm64_v8a ships to devices; x86_64 is the testability ABI (cibuildwheel's
#    emulator testbed only runs the build-host arch).
cibuildwheel --only cp314-android_arm64_v8a --output-dir wheelhouse
cibuildwheel --only cp314-android_x86_64    --output-dir wheelhouse

The frontend must be build/uv (Android does not support the pip frontend); this is set in pyproject.toml. CPython 3.15 (pre-release until Oct 2026) additionally needs --enable cpython-prerelease.

16 KB page alignment. Android 15/16 requires native .sos to have 16 KB-aligned LOAD segments. The build pins LDFLAGS = "-Wl,-z,max-page-size=16384" in [tool.cibuildwheel.android] so the wheel is 16 KB-aligned independent of the NDK’s default. This aligns only PyJNIus’s .so – the packager is responsible for aligning the bootstrap libs (SDL, libpython, …) and for 16 KB zip-aligning the final APK.

Verifying a built wheel

# unzip the wheel, then, with the NDK's llvm-readelf:
llvm-readelf -d jnius/jnius*.so | grep NEEDED     # no libSDL / libnativehelper / libart
llvm-readelf -l jnius/jnius*.so | grep LOAD       # Align column == 0x4000 (16 KB)
llvm-readelf --dyn-syms jnius/jnius*.so | grep -E 'dlopen|dlsym'   # runtime-resolved

The wheel should contain only the .so and the Python modules – no src/org, .java or .class.