From 1acd26a738c5504b6ce961d8065e7149b3035241 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 06:41:16 -0700 Subject: [PATCH] ci: build the Android app A new android lane runs Clippy on mobile through platform/android/cargo.sh, then builds the APK with apps/android/build.py, as the app ships it. It finds the NDK, JDK and SDK through ANDROID_NDK_HOME, JAVA_HOME and ANDROID_HOME or in target/android-toolchain, and the Android Rust target in the default rustup or the one there, and skips, saying why, without them. Changes under apps/android or platform/android, or reaching mobile, select it. As with iOS, the emulator check stays out of CI. Refs #24 Assisted-by: claude-opus-5.5 --- AGENTS.md | 2 +- apps/android/build.py | 19 +++++++++++++------ platform/android/README.md | 7 +++---- tools/TESTING.md | 1 + tools/ci.py | 35 ++++++++++++++++++++++++++++++++++- 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1736a094db53fa216108f3dc5a72253f9661b228..6715bcf1fac6d234be7b2b66a87346edfea5c51f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ novel UI kit, each new platform port is extremely lightweight. | `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw | | `crates/ui` | The immediate-mode interface kit and OneNote's chrome controls. Knows nothing of notebooks. | draw | | `crates/snowbound` | The desktop app: winit window, platform glue, sidebar, menus, templates. | all of the above | -| `crates/mobile`, `apps/ios` | A C surface over canvas and notebook, and the UIKit app built on it. | canvas, notebook, and below | +| `crates/mobile`, `apps/ios`, `apps/android` | A C surface over canvas and notebook, and the UIKit and Android apps built on it. | canvas, notebook, and below | | `tools/`, `corpus/`, `fuzz/` | Lab harnesses (Python, PowerShell, AutoHotkey), the corpus of OneNote-verified files, and a separate cargo-fuzz workspace. | | Dependencies point from the apps toward the format, never back. The format diff --git a/apps/android/build.py b/apps/android/build.py index 32a9845c472666d366c424df99c190b046d01d29..55ecb6366e6612d26b8cdeca17bbf9365243219b 100644 --- a/apps/android/build.py +++ b/apps/android/build.py @@ -9,6 +9,7 @@ which needs the aarch64-linux-android target (RUSTUP_HOME can name another rustu import argparse import os from pathlib import Path +import re import shutil import subprocess import sys @@ -18,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / 'tools')) from cargo_fresh import clean_local # noqa: E402 -API = 26 +SOURCE = Path(__file__).resolve().parent +API = int(re.search(r'android:minSdkVersion="(\d+)"', (SOURCE / 'AndroidManifest.xml').read_text())[1]) TARGET = 'aarch64-linux-android' @@ -32,6 +34,7 @@ def main(): parser.add_argument('--ndk', type=Path, required=True) parser.add_argument('--java-home', type=Path, required=True) parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--clippy', action='store_true', help='Clippy mobile first, as CI does') args = parser.parse_args() def sdk(path): @@ -47,17 +50,21 @@ def main(): llvm = ndk / 'toolchains/llvm/prebuilt' / ('darwin-x86_64' if sys.platform == 'darwin' else 'linux-x86_64') / 'bin' output = args.output.resolve() output.mkdir(parents=True, exist_ok=True) - source = Path(__file__).resolve().parent environment = dict(os.environ, ANDROID_NDK_HOME=str(ndk)) environment.setdefault('CARGO_TARGET_DIR', str(ROOT / 'target/android')) + if args.clippy: + clean_local(ROOT, '--target', TARGET, environment=environment) + run('sh', ROOT / 'platform/android/cargo.sh', API, 'clippy', '-p', 'mobile', '--lib', '--', '-D', 'warnings', + env=environment) # A debug build has wgpu name GPU objects for debuggers, which crashes the emulator's Vulkan driver. clean_local(ROOT, '--release', '--target', TARGET, environment=environment) run('sh', ROOT / 'platform/android/cargo.sh', API, 'build', '--release', '-p', 'mobile', env=environment) library = output / 'libsnowbound.so' run(llvm / f'aarch64-linux-android{API}-clang', '-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-fPIC', '-shared', '-Wl,--no-undefined', '-Wl,--exclude-libs,ALL', '-Wl,--gc-sections', '-Wl,--strip-debug', - '-Wl,-z,max-page-size=16384', '-Wl,-soname,libsnowbound.so', '-I', ROOT / 'crates/mobile/include', source / 'jni/snowbound.c', + '-Wl,-z,max-page-size=16384', '-Wl,-soname,libsnowbound.so', '-I', ROOT / 'crates/mobile/include', + SOURCE / 'jni/snowbound.c', Path(environment['CARGO_TARGET_DIR']) / TARGET / 'release/libmobile.a', '-landroid', '-llog', '-ldl', '-lm', '-o', library) # Android 15 devices with 16 KiB pages load only libraries aligned to them. @@ -69,13 +76,13 @@ def main(): classes = output / 'classes' shutil.rmtree(classes, ignore_errors=True) run(java / 'javac', '-Xlint:-options', '--release', '8', '-classpath', android, '-d', classes, - *sorted((source / 'src').rglob('*.java'))) + *sorted((SOURCE / 'src').rglob('*.java'))) run(java / 'java', '-cp', tools / 'lib/d8.jar', 'com.android.tools.r8.D8', '--min-api', API, '--lib', android, '--output', output, *sorted(classes.rglob('*.class'))) resources = output / 'resources.zip' - run(tools / 'aapt2', 'compile', '--dir', source / 'res', '-o', resources) + run(tools / 'aapt2', 'compile', '--dir', SOURCE / 'res', '-o', resources) unsigned = output / 'unsigned.apk' - run(tools / 'aapt2', 'link', '--debug-mode', '-I', android, '--manifest', source / 'AndroidManifest.xml', + run(tools / 'aapt2', 'link', '--debug-mode', '-I', android, '--manifest', SOURCE / 'AndroidManifest.xml', '-o', unsigned, resources) with zipfile.ZipFile(unsigned, 'a') as apk: apk.write(output / 'classes.dex', 'classes.dex', zipfile.ZIP_DEFLATED) diff --git a/platform/android/README.md b/platform/android/README.md index bcd90319d07fffd3d6ca06493b1b6448b202aad4..3afbe4965693c0c344658d99d0296fbb524a5347 100644 --- a/platform/android/README.md +++ b/platform/android/README.md @@ -23,10 +23,9 @@ The native static library is `target/android/aarch64-linux-android/debug/libmobi shared build cache. Clean local package artifacts with `tools/cargo_fresh.py` before verifying a different checkout against that cache. -This lane checks and builds native code. It does not create an APK or demonstrate -an Android lifecycle, provider storage, or rendering on a device. The Android -host must link its final shared library with the selected NDK and verify ELF -alignment for 16 KB page-size devices. +This lane checks and builds native code. `apps/android/build.py` builds the app on it: +it links the shared library with the same NDK, checks its alignment for 16 KB +page-size devices, and packages and signs the APK. Tool selection and prerequisite failures can be checked without an NDK: diff --git a/tools/TESTING.md b/tools/TESTING.md index cc82bb4ce5b3de129294d034a147849aa009a3b0..1cef9bc83753783d1daaa6614826d9d84427844d 100644 --- a/tools/TESTING.md +++ b/tools/TESTING.md @@ -30,6 +30,7 @@ cleanup must run serially across checkouts. | `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) | | `windows-aarch64`, `linux-*` | Clippy `-D warnings` on what ships (libraries and binaries but `mobile`), then the `snowbound` build; Linux through `platform/linux/cargo.sh`, which links with zig against glibc 2.17 | | `ios` | `xcodebuild` of the simulator app, unsigned | +| `android` | Clippy `-D warnings` on `mobile` through `platform/android/cargo.sh`, then `apps/android/build.py`'s APK; skipped, saying why, without the NDK, SDK, JDK or the `aarch64-linux-android` target | | `web` | Clippy `-D warnings` on `snowbound` for `wasm32-unknown-unknown`, SQLite built by nixpkgs' clang; `release_web.py` links, optimizes and deploys the static folder | | `web-js` | Node boundary tests for Live Share browser sockets and storage | | `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` | diff --git a/tools/ci.py b/tools/ci.py index 5b209fcd11a1df5745a90fef200bc43fe8d9ea07..562098f2f88e43bc5303897a1759cec125fe29ee 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -43,7 +43,8 @@ def lanes(): """Each lane: its commands, the packages whose dependencies (None for all) and the paths that select it under --changed, extra environment, and why it can't run here, if so.""" clippy = ['clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings'] - # What ships: mobile is iOS's alone, and the tests and lab examples run only on macOS. + # What ships: mobile is the phone apps', with lanes of their own, and the tests and lab + # examples run only on macOS. cross_clippy = ['clippy', '--workspace', '--exclude', 'mobile', '--lib', '--bins', '--all-features', '--', '-D', 'warnings'] windows = CI / 'platform/windows/cargo.sh' @@ -104,6 +105,14 @@ def lanes(): '-configuration', 'Debug', '-destination', 'generic/platform=iOS Simulator', '-derivedDataPath', TARGET / 'ios-xcode', 'CODE_SIGNING_ALLOWED=NO', 'build']], missing=None if shutil.which('xcodebuild') else 'needs Xcode')) + ndk, jdk, sdks, rustup, missing = android_toolchain() + result.append(dict( + name='android', minutes=45, packages=['mobile'], paths=('apps/android/', 'platform/android/'), + # build.py cleans its own artifacts, as it reads the API level from the app's manifest. + commands=[['python3', 'apps/android/build.py', '--clippy', *(arg for sdk in sdks for arg in ('--sdk', sdk)), + '--ndk', ndk, '--java-home', jdk, '--output', TARGET / 'android-apk']], + environment={'ANDROID_NDK_HOME': str(ndk), **({'RUSTUP_HOME': str(rustup)} if rustup else {})}, + missing=missing)) result.append(dict( name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',), clean=['sh', CI / 'platform/snow-leopard/cargo.sh', 'clean', '--locked'], @@ -115,6 +124,30 @@ def lanes(): return result +def android_toolchain(): + """The NDK, JDK and SDK folders the Android lane builds with, as ANDROID_NDK_HOME, + JAVA_HOME and ANDROID_HOME name them or target/android-toolchain holds them; a rustup home + for the Android target where the default lacks it; and why the lane can't run, if so.""" + tools = MAIN / 'target/android-toolchain' + + def found(variable, *patterns): + named = os.environ.get(variable) + candidates = [Path(named)] if named else [path for pattern in patterns for path in sorted(tools.glob(pattern))] + return next((path for path in candidates if path.is_dir()), None) + + ndk = found('ANDROID_NDK_HOME', 'android-ndk-*') + jdk = found('JAVA_HOME', 'jdk-*/Contents/Home', 'jdk-*') + sdks = [path for path in (os.environ.get('ANDROID_HOME'), Path.home() / 'Library/Android/sdk', + tools / 'provider-sdk') if path and Path(path).is_dir()] + target = 'aarch64-linux-android' + installed = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout + rustup = None if target in installed.split() else tools / 'rustup' + missing = (f'needs the {target} target' if rustup and not any(rustup.glob(f'toolchains/*/lib/rustlib/{target}')) + else 'needs the Android NDK' if not ndk else 'needs a JDK' if not jdk + else 'needs the Android SDK' if not sdks else None) + return ndk, jdk, sdks, rustup, missing + + def web_environment(): """A clang and llvm-ar that build SQLite for wasm32, as release_web.py finds them; none where it finds none.""" -- 2.54.0