| 1 | #!/usr/bin/env python3 |
| 2 | """Builds Snowbound's ARM64 APK, signed with a debug key kept in the output folder. |
| 3 | |
| 4 | python3 apps/android/build.py --sdk SDK [--sdk SDK...] --ndk NDK --java-home JDK --output DIR |
| 5 | |
| 6 | Each --sdk is searched for build-tools and the platform. Rust comes from the rustup on PATH, |
| 7 | which needs the aarch64-linux-android target (RUSTUP_HOME can name another rustup's home). |
| 8 | """ |
| 9 | import argparse |
| 10 | import os |
| 11 | from pathlib import Path |
| 12 | import re |
| 13 | import shutil |
| 14 | import subprocess |
| 15 | import sys |
| 16 | import zipfile |
| 17 | |
| 18 | ROOT = Path(__file__).resolve().parents[2] |
| 19 | sys.path.insert(0, str(ROOT / 'tools')) |
| 20 | from cargo_fresh import clean_local # noqa: E402 |
| 21 | |
| 22 | SOURCE = Path(__file__).resolve().parent |
| 23 | API = int(re.search(r'android:minSdkVersion="(\d+)"', (SOURCE / 'AndroidManifest.xml').read_text())[1]) |
| 24 | TARGET = 'aarch64-linux-android' |
| 25 | |
| 26 | |
| 27 | def run(*command, **options): |
| 28 | subprocess.run([str(part) for part in command], check=True, **options) |
| 29 | |
| 30 | |
| 31 | def main(): |
| 32 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 33 | parser.add_argument('--sdk', type=Path, action='append', required=True) |
| 34 | parser.add_argument('--ndk', type=Path, required=True) |
| 35 | parser.add_argument('--java-home', type=Path, required=True) |
| 36 | parser.add_argument('--output', type=Path, required=True) |
| 37 | parser.add_argument('--clippy', action='store_true', help='Clippy mobile first, as CI does') |
| 38 | args = parser.parse_args() |
| 39 | |
| 40 | def sdk(path): |
| 41 | found = [root / path for root in args.sdk if (root / path).exists()] |
| 42 | if not found: |
| 43 | sys.exit(f'No --sdk has {path}') |
| 44 | return found[0].resolve() |
| 45 | |
| 46 | tools = sdk(Path('build-tools/36.0.0')) |
| 47 | android = sdk(Path('platforms/android-36/android.jar')) |
| 48 | java = args.java_home.resolve() / 'bin' |
| 49 | ndk = args.ndk.resolve() |
| 50 | llvm = ndk / 'toolchains/llvm/prebuilt' / ('darwin-x86_64' if sys.platform == 'darwin' else 'linux-x86_64') / 'bin' |
| 51 | output = args.output.resolve() |
| 52 | output.mkdir(parents=True, exist_ok=True) |
| 53 | |
| 54 | environment = dict(os.environ, ANDROID_NDK_HOME=str(ndk)) |
| 55 | environment.setdefault('CARGO_TARGET_DIR', str(ROOT / 'target/android')) |
| 56 | if args.clippy: |
| 57 | clean_local(ROOT, '--target', TARGET, environment=environment) |
| 58 | run('sh', ROOT / 'platform/android/cargo.sh', API, 'clippy', '-p', 'mobile', '--lib', '--', '-D', 'warnings', |
| 59 | env=environment) |
| 60 | # A debug build has wgpu name GPU objects for debuggers, which crashes the emulator's Vulkan driver. |
| 61 | clean_local(ROOT, '--release', '--target', TARGET, environment=environment) |
| 62 | run('sh', ROOT / 'platform/android/cargo.sh', API, 'build', '--release', '-p', 'mobile', env=environment) |
| 63 | library = output / 'libsnowbound.so' |
| 64 | run(llvm / f'aarch64-linux-android{API}-clang', '-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-fPIC', |
| 65 | '-shared', '-Wl,--no-undefined', '-Wl,--exclude-libs,ALL', '-Wl,--gc-sections', '-Wl,--strip-debug', |
| 66 | '-Wl,-z,max-page-size=16384', '-Wl,-soname,libsnowbound.so', '-I', ROOT / 'crates/mobile/include', |
| 67 | SOURCE / 'jni/snowbound.c', |
| 68 | Path(environment['CARGO_TARGET_DIR']) / TARGET / 'release/libmobile.a', |
| 69 | '-landroid', '-llog', '-ldl', '-lm', '-o', library) |
| 70 | # Android 15 devices with 16 KiB pages load only libraries aligned to them. |
| 71 | segments = subprocess.run([llvm / 'llvm-readelf', '-lW', library], check=True, capture_output=True, |
| 72 | text=True).stdout |
| 73 | if any(int(line.split()[-1], 16) < 0x4000 for line in segments.splitlines() if line.strip().startswith('LOAD')): |
| 74 | sys.exit(f'{library} has a LOAD segment aligned below 16 KiB') |
| 75 | |
| 76 | classes = output / 'classes' |
| 77 | shutil.rmtree(classes, ignore_errors=True) |
| 78 | run(java / 'javac', '-Xlint:-options', '--release', '8', '-classpath', android, '-d', classes, |
| 79 | *sorted((SOURCE / 'src').rglob('*.java'))) |
| 80 | run(java / 'java', '-cp', tools / 'lib/d8.jar', 'com.android.tools.r8.D8', '--min-api', API, |
| 81 | '--lib', android, '--output', output, *sorted(classes.rglob('*.class'))) |
| 82 | resources = output / 'resources.zip' |
| 83 | run(tools / 'aapt2', 'compile', '--dir', SOURCE / 'res', '-o', resources) |
| 84 | unsigned = output / 'unsigned.apk' |
| 85 | run(tools / 'aapt2', 'link', '--debug-mode', '-I', android, '--manifest', SOURCE / 'AndroidManifest.xml', |
| 86 | '-o', unsigned, resources) |
| 87 | with zipfile.ZipFile(unsigned, 'a') as apk: |
| 88 | apk.write(output / 'classes.dex', 'classes.dex', zipfile.ZIP_DEFLATED) |
| 89 | # Android maps the library from the APK, so it stays uncompressed. |
| 90 | apk.write(library, 'lib/arm64-v8a/libsnowbound.so', zipfile.ZIP_STORED) |
| 91 | aligned = output / 'aligned.apk' |
| 92 | run(tools / 'zipalign', '-f', '-P', '16', '4', unsigned, aligned) |
| 93 | keystore = output / 'debug.keystore' |
| 94 | if not keystore.exists(): |
| 95 | run(java / 'keytool', '-genkeypair', '-keystore', keystore, '-storepass', 'android', '-alias', 'debug', |
| 96 | '-keyalg', 'RSA', '-validity', '10000', '-dname', 'CN=Snowbound Debug') |
| 97 | apk = output / 'Snowbound.apk' |
| 98 | run(java / 'java', '-jar', tools / 'lib/apksigner.jar', 'sign', '--ks', keystore, '--ks-pass', 'pass:android', |
| 99 | '--out', apk, aligned) |
| 100 | run(java / 'java', '-jar', tools / 'lib/apksigner.jar', 'verify', apk) |
| 101 | run(tools / 'zipalign', '-c', '-P', '16', '4', apk) |
| 102 | print(apk) |
| 103 | |
| 104 | |
| 105 | if __name__ == '__main__': |
| 106 | main() |