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
6Each --sdk is searched for build-tools and the platform. Rust comes from the rustup on PATH,
7which needs the aarch64-linux-android target (RUSTUP_HOME can name another rustup's home).
8"""
9import argparse
10import os
11from pathlib import Path
12import re
13import shutil
14import subprocess
15import sys
16import zipfile
17
18ROOT = Path(__file__).resolve().parents[2]
19sys.path.insert(0, str(ROOT / 'tools'))
20from cargo_fresh import clean_local # noqa: E402
21
22SOURCE = Path(__file__).resolve().parent
23API = int(re.search(r'android:minSdkVersion="(\d+)"', (SOURCE / 'AndroidManifest.xml').read_text())[1])
24TARGET = 'aarch64-linux-android'
25
26
27def run(*command, **options):
28 subprocess.run([str(part) for part in command], check=True, **options)
29
30
31def 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
105if __name__ == '__main__':
106 main()