Fry3DX/android/build_apk.py
Nexoniarz f6f5e5f288 Fry3DX 0.5: builds for Windows, Linux and Android, a phone layout, Apache 2.0
- Apache License 2.0
- Windows: cross-built with clang-cl (cmake --preset windows), a window
  program with its icon; Linux: a portable preset (SDL and the C++
  library inside); Android: android/build_apk.py makes the APK
- tools/package.py packs the downloads; a GitHub workflow builds all
  three and makes a release for a v* tag
- A phone layout (fry_touch, on by default on Android): the UI drawn
  bigger, panels when asked for, a bar of big buttons, one finger to
  turn the view, two to move and pinch, a long press for a right-click;
  files through the phone's file picker
- Settings and autosaves now in a Nexoniarz folder (copied over once)
- README rewritten, the website shows the downloads and the phone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP
2026-10-19 22:05:01 +02:00

116 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Builds Fry3DX's Android APK (arm64, Vulkan) with no Gradle: the NDK build (cmake --preset
android), the content as plain files in assets/, SDL's Java layer, the icon, then aapt2, d8,
zipalign and apksigner.
nix-shell android/shell.nix --run "python3 android/build_apk.py [--install]"
Out: dist/Fry3DX-<version>-android-arm64.apk. --install puts it on a phone over adb (USB
debugging on) and starts it. The first start copies the content out (a moment).
Signed with ~/.local/share/Nexoniarz/Fry3DX-signing/release.keystore (made the first time),
or the key in $FRY_KEYSTORE / $FRY_KEYSTORE_PASS. Keep it safe and keep using it: Android only
updates an app signed with the key it was installed with.
"""
import os
import secrets
import shutil
import subprocess
import sys
import time
import zipfile
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
BUILD = os.path.join(ROOT, "build", "android")
APP = "Fry3DX"
PACKAGE = "io.github.nexoniarz.fry3dx"
def run(*cmd, **kw):
subprocess.run(list(cmd), check=True, **kw)
def app_version():
with open(os.path.join(ROOT, "CMakeLists.txt")) as f:
for line in f:
if line.startswith("project(Fry3DX VERSION "):
return line.split()[2]
return "0"
def main():
sdk, ndk = os.environ["ANDROID_HOME"], os.environ["ANDROID_NDK_ROOT"]
tools = os.path.join(sdk, "build-tools", "35.0.0")
android_jar = os.path.join(sdk, "platforms", "android-35", "android.jar")
llvm = os.path.join(ndk, "toolchains", "llvm", "prebuilt", "linux-x86_64", "bin")
if not os.path.isdir(BUILD):
glslc = os.environ.get("GLSLC") or os.path.join(ndk, "shader-tools", "linux-x86_64", "glslc") # (the NDK's own, if none's given)
run("cmake", "--preset", "android", *([f"-DGLSLC={glslc}"] if os.path.exists(glslc) else []), cwd=ROOT)
run("cmake", "--build", "--preset", "android", cwd=ROOT)
stage = os.path.join(BUILD, "apk")
shutil.rmtree(stage, ignore_errors=True)
assets, libs = os.path.join(stage, "assets"), os.path.join(stage, "lib", "arm64-v8a")
os.makedirs(libs)
bin_dir = os.path.join(BUILD, "bin")
sdl = os.path.join(BUILD, "_deps", "sdl3-build", "libSDL3.so")
for src in (os.path.join(bin_dir, "libmain.so"), sdl):
run(os.path.join(llvm, "llvm-strip"), "--strip-debug", "-o", os.path.join(libs, os.path.basename(src)), src)
# Content: the folders next to the program on a desktop, and the list it unpacks from (a
# version on its first line, so an update unpacks again).
for d in ("engine", APP):
shutil.copytree(os.path.join(bin_dir, d), os.path.join(assets, d))
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip() or "local"
version = f"{app_version()}-{commit}"
files = [os.path.relpath(os.path.join(r, n), assets).replace(os.sep, "/") for r, _, ns in os.walk(assets) for n in ns]
with open(os.path.join(assets, "content.txt"), "w") as f:
f.write(f"{version}-{int(time.time())}\n" + "\n".join(sorted(files)) + "\n")
# Java: SDL's activity and helpers, and ours on top; then dex.
java_src = [os.path.join(ROOT, "android", "java"), os.path.join(BUILD, "_deps", "sdl3-src", "android-project", "app", "src", "main", "java")]
sources = [os.path.join(r, n) for d in java_src for r, _, ns in os.walk(d) for n in ns if n.endswith(".java")]
classes = os.path.join(BUILD, "java-classes")
shutil.rmtree(classes, ignore_errors=True)
os.makedirs(classes)
print(f"Compiling {len(sources)} Java files")
run("javac", "-nowarn", "-source", "8", "-target", "8", "-bootclasspath", android_jar, "-d", classes, *sources,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
class_files = [os.path.join(r, n) for r, _, ns in os.walk(classes) for n in ns if n.endswith(".class")]
run(os.path.join(tools, "d8"), "--release", "--min-api", "28", "--lib", android_jar, "--output", stage, *class_files)
# Resources (the icon), the manifest and the assets; then dex and libraries; aligned, signed.
res_zip = os.path.join(BUILD, "res.zip")
run(os.path.join(tools, "aapt2"), "compile", "--dir", os.path.join(ROOT, "android", "res"), "-o", res_zip)
unsigned = os.path.join(BUILD, "unsigned.apk")
vcode = str(int(time.time()) // 60 % 2000000000)
run(os.path.join(tools, "aapt2"), "link", "-o", unsigned, "-I", android_jar, "--manifest", os.path.join(ROOT, "android", "AndroidManifest.xml"),
"-A", assets, "-R", res_zip, "--auto-add-overlay", "--min-sdk-version", "28", "--target-sdk-version", "35",
"--version-code", vcode, "--version-name", version)
with zipfile.ZipFile(unsigned, "a") as z:
z.write(os.path.join(stage, "classes.dex"), "classes.dex")
for n in os.listdir(libs):
z.write(os.path.join(libs, n), f"lib/arm64-v8a/{n}")
aligned = os.path.join(BUILD, "aligned.apk")
run(os.path.join(tools, "zipalign"), "-p", "-f", "4", unsigned, aligned)
keystore = os.environ.get("FRY_KEYSTORE") or os.path.expanduser("~/.local/share/Nexoniarz/Fry3DX-signing/release.keystore")
pass_file = keystore + ".pass" # (its password, beside it, when made here)
password = os.environ.get("FRY_KEYSTORE_PASS") or (open(pass_file).read().strip() if os.path.exists(pass_file) else "")
if not os.path.exists(keystore):
os.makedirs(os.path.dirname(keystore), exist_ok=True)
password = password or secrets.token_urlsafe(24)
with open(os.open(pass_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
f.write(password + "\n")
run("keytool", "-genkeypair", "-keystore", keystore, "-storepass", password, "-keypass", password, "-alias", "fry3dx",
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000", "-dname", "CN=Fry3DX", stdout=subprocess.DEVNULL)
os.makedirs(os.path.join(ROOT, "dist"), exist_ok=True)
out = os.path.join(ROOT, "dist", f"{APP}-{app_version()}-android-arm64.apk")
run(os.path.join(tools, "apksigner"), "sign", "--ks", keystore, "--ks-pass", f"pass:{password}", "--out", out, aligned)
print(f"{os.path.relpath(out, ROOT)}: {os.path.getsize(out) // (1024 * 1024)} MB")
if "--install" in sys.argv:
run("adb", "install", "-r", out)
run("adb", "shell", "am", "start", "-n", f"{PACKAGE}/.FryActivity")
if __name__ == "__main__":
main()