authorclover caruso <git@paperclover.net> 2026-10-15 06:41:16-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 07:51:47-07:00
log1acd26a738c5504b6ce961d8065e7149b3035241
tree0ff61991238b910ecdc1edbabdc48567ea641e3f
parentd344bc604819cc97912c0f7deccaa3753f7d8c55
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

5 files changed, 52 insertions(+), 12 deletions(-)

AGENTS.md+1-1
...@@ -19,7 +19,7 @@ novel UI kit, each new platform port is extremely lightweight....@@ -19,7 +19,7 @@ novel UI kit, each new platform port is extremely lightweight.
19| `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw |19| `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw |
20| `crates/ui` | The immediate-mode interface kit and OneNote's chrome controls. Knows nothing of notebooks. | draw |20| `crates/ui` | The immediate-mode interface kit and OneNote's chrome controls. Knows nothing of notebooks. | draw |
21| `crates/snowbound` | The desktop app: winit window, platform glue, sidebar, menus, templates. | all of the above |21| `crates/snowbound` | The desktop app: winit window, platform glue, sidebar, menus, templates. | all of the above |
22| `crates/mobile`, `apps/ios` | A C surface over canvas and notebook, and the UIKit app built on it. | canvas, notebook, and below |22| `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 |
23| `tools/`, `corpus/`, `fuzz/` | Lab harnesses (Python, PowerShell, AutoHotkey), the corpus of OneNote-verified files, and a separate cargo-fuzz workspace. | |23| `tools/`, `corpus/`, `fuzz/` | Lab harnesses (Python, PowerShell, AutoHotkey), the corpus of OneNote-verified files, and a separate cargo-fuzz workspace. | |
2424
25Dependencies point from the apps toward the format, never back. The format25Dependencies point from the apps toward the format, never back. The format
apps/android/build.py+13-6
...@@ -9,6 +9,7 @@ which needs the aarch64-linux-android target (RUSTUP_HOME can name another rustu...@@ -9,6 +9,7 @@ which needs the aarch64-linux-android target (RUSTUP_HOME can name another rustu
9import argparse9import argparse
10import os10import os
11from pathlib import Path11from pathlib import Path
12import re
12import shutil13import shutil
13import subprocess14import subprocess
14import sys15import sys
...@@ -18,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[2]...@@ -18,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[2]
18sys.path.insert(0, str(ROOT / 'tools'))19sys.path.insert(0, str(ROOT / 'tools'))
19from cargo_fresh import clean_local # noqa: E40220from cargo_fresh import clean_local # noqa: E402
2021
21API = 2622SOURCE = Path(__file__).resolve().parent
23API = int(re.search(r'android:minSdkVersion="(\d+)"', (SOURCE / 'AndroidManifest.xml').read_text())[1])
22TARGET = 'aarch64-linux-android'24TARGET = 'aarch64-linux-android'
2325
2426
...@@ -32,6 +34,7 @@ def main():...@@ -32,6 +34,7 @@ def main():
32 parser.add_argument('--ndk', type=Path, required=True)34 parser.add_argument('--ndk', type=Path, required=True)
33 parser.add_argument('--java-home', type=Path, required=True)35 parser.add_argument('--java-home', type=Path, required=True)
34 parser.add_argument('--output', 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')
35 args = parser.parse_args()38 args = parser.parse_args()
3639
37 def sdk(path):40 def sdk(path):
...@@ -47,17 +50,21 @@ def main():...@@ -47,17 +50,21 @@ def main():
47 llvm = ndk / 'toolchains/llvm/prebuilt' / ('darwin-x86_64' if sys.platform == 'darwin' else 'linux-x86_64') / 'bin'50 llvm = ndk / 'toolchains/llvm/prebuilt' / ('darwin-x86_64' if sys.platform == 'darwin' else 'linux-x86_64') / 'bin'
48 output = args.output.resolve()51 output = args.output.resolve()
49 output.mkdir(parents=True, exist_ok=True)52 output.mkdir(parents=True, exist_ok=True)
50 source = Path(__file__).resolve().parent
5153
52 environment = dict(os.environ, ANDROID_NDK_HOME=str(ndk))54 environment = dict(os.environ, ANDROID_NDK_HOME=str(ndk))
53 environment.setdefault('CARGO_TARGET_DIR', str(ROOT / 'target/android'))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)
54 # A debug build has wgpu name GPU objects for debuggers, which crashes the emulator's Vulkan driver.60 # A debug build has wgpu name GPU objects for debuggers, which crashes the emulator's Vulkan driver.
55 clean_local(ROOT, '--release', '--target', TARGET, environment=environment)61 clean_local(ROOT, '--release', '--target', TARGET, environment=environment)
56 run('sh', ROOT / 'platform/android/cargo.sh', API, 'build', '--release', '-p', 'mobile', env=environment)62 run('sh', ROOT / 'platform/android/cargo.sh', API, 'build', '--release', '-p', 'mobile', env=environment)
57 library = output / 'libsnowbound.so'63 library = output / 'libsnowbound.so'
58 run(llvm / f'aarch64-linux-android{API}-clang', '-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-fPIC',64 run(llvm / f'aarch64-linux-android{API}-clang', '-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-fPIC',
59 '-shared', '-Wl,--no-undefined', '-Wl,--exclude-libs,ALL', '-Wl,--gc-sections', '-Wl,--strip-debug',65 '-shared', '-Wl,--no-undefined', '-Wl,--exclude-libs,ALL', '-Wl,--gc-sections', '-Wl,--strip-debug',
60 '-Wl,-z,max-page-size=16384', '-Wl,-soname,libsnowbound.so', '-I', ROOT / 'crates/mobile/include', source / 'jni/snowbound.c',66 '-Wl,-z,max-page-size=16384', '-Wl,-soname,libsnowbound.so', '-I', ROOT / 'crates/mobile/include',
67 SOURCE / 'jni/snowbound.c',
61 Path(environment['CARGO_TARGET_DIR']) / TARGET / 'release/libmobile.a',68 Path(environment['CARGO_TARGET_DIR']) / TARGET / 'release/libmobile.a',
62 '-landroid', '-llog', '-ldl', '-lm', '-o', library)69 '-landroid', '-llog', '-ldl', '-lm', '-o', library)
63 # Android 15 devices with 16 KiB pages load only libraries aligned to them.70 # Android 15 devices with 16 KiB pages load only libraries aligned to them.
...@@ -69,13 +76,13 @@ def main():...@@ -69,13 +76,13 @@ def main():
69 classes = output / 'classes'76 classes = output / 'classes'
70 shutil.rmtree(classes, ignore_errors=True)77 shutil.rmtree(classes, ignore_errors=True)
71 run(java / 'javac', '-Xlint:-options', '--release', '8', '-classpath', android, '-d', classes,78 run(java / 'javac', '-Xlint:-options', '--release', '8', '-classpath', android, '-d', classes,
72 *sorted((source / 'src').rglob('*.java')))79 *sorted((SOURCE / 'src').rglob('*.java')))
73 run(java / 'java', '-cp', tools / 'lib/d8.jar', 'com.android.tools.r8.D8', '--min-api', API,80 run(java / 'java', '-cp', tools / 'lib/d8.jar', 'com.android.tools.r8.D8', '--min-api', API,
74 '--lib', android, '--output', output, *sorted(classes.rglob('*.class')))81 '--lib', android, '--output', output, *sorted(classes.rglob('*.class')))
75 resources = output / 'resources.zip'82 resources = output / 'resources.zip'
76 run(tools / 'aapt2', 'compile', '--dir', source / 'res', '-o', resources)83 run(tools / 'aapt2', 'compile', '--dir', SOURCE / 'res', '-o', resources)
77 unsigned = output / 'unsigned.apk'84 unsigned = output / 'unsigned.apk'
78 run(tools / 'aapt2', 'link', '--debug-mode', '-I', android, '--manifest', source / 'AndroidManifest.xml',85 run(tools / 'aapt2', 'link', '--debug-mode', '-I', android, '--manifest', SOURCE / 'AndroidManifest.xml',
79 '-o', unsigned, resources)86 '-o', unsigned, resources)
80 with zipfile.ZipFile(unsigned, 'a') as apk:87 with zipfile.ZipFile(unsigned, 'a') as apk:
81 apk.write(output / 'classes.dex', 'classes.dex', zipfile.ZIP_DEFLATED)88 apk.write(output / 'classes.dex', 'classes.dex', zipfile.ZIP_DEFLATED)
platform/android/README.md+3-4
...@@ -23,10 +23,9 @@ The native static library is `target/android/aarch64-linux-android/debug/libmobi...@@ -23,10 +23,9 @@ The native static library is `target/android/aarch64-linux-android/debug/libmobi
23shared build cache. Clean local package artifacts with `tools/cargo_fresh.py`23shared build cache. Clean local package artifacts with `tools/cargo_fresh.py`
24before verifying a different checkout against that cache.24before verifying a different checkout against that cache.
2525
26This lane checks and builds native code. It does not create an APK or demonstrate26This lane checks and builds native code. `apps/android/build.py` builds the app on it:
27an Android lifecycle, provider storage, or rendering on a device. The Android27it links the shared library with the same NDK, checks its alignment for 16 KB
28host must link its final shared library with the selected NDK and verify ELF28page-size devices, and packages and signs the APK.
29alignment for 16 KB page-size devices.
3029
31Tool selection and prerequisite failures can be checked without an NDK:30Tool selection and prerequisite failures can be checked without an NDK:
3231
tools/TESTING.md+1
...@@ -30,6 +30,7 @@ cleanup must run serially across checkouts....@@ -30,6 +30,7 @@ cleanup must run serially across checkouts.
30| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |30| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |
31| `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 |31| `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 |
32| `ios` | `xcodebuild` of the simulator app, unsigned |32| `ios` | `xcodebuild` of the simulator app, unsigned |
33| `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 |
33| `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 |34| `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 |
34| `web-js` | Node boundary tests for Live Share browser sockets and storage |35| `web-js` | Node boundary tests for Live Share browser sockets and storage |
35| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |36| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |
tools/ci.py+34-1
...@@ -43,7 +43,8 @@ def lanes():...@@ -43,7 +43,8 @@ def lanes():
43 """Each lane: its commands, the packages whose dependencies (None for all) and the paths43 """Each lane: its commands, the packages whose dependencies (None for all) and the paths
44 that select it under --changed, extra environment, and why it can't run here, if so."""44 that select it under --changed, extra environment, and why it can't run here, if so."""
45 clippy = ['clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings']45 clippy = ['clippy', '--workspace', '--all-targets', '--all-features', '--', '-D', 'warnings']
46 # What ships: mobile is iOS's alone, and the tests and lab examples run only on macOS.46 # What ships: mobile is the phone apps', with lanes of their own, and the tests and lab
47 # examples run only on macOS.
47 cross_clippy = ['clippy', '--workspace', '--exclude', 'mobile', '--lib', '--bins', '--all-features',48 cross_clippy = ['clippy', '--workspace', '--exclude', 'mobile', '--lib', '--bins', '--all-features',
48 '--', '-D', 'warnings']49 '--', '-D', 'warnings']
49 windows = CI / 'platform/windows/cargo.sh'50 windows = CI / 'platform/windows/cargo.sh'
...@@ -104,6 +105,14 @@ def lanes():...@@ -104,6 +105,14 @@ def lanes():
104 '-configuration', 'Debug', '-destination', 'generic/platform=iOS Simulator',105 '-configuration', 'Debug', '-destination', 'generic/platform=iOS Simulator',
105 '-derivedDataPath', TARGET / 'ios-xcode', 'CODE_SIGNING_ALLOWED=NO', 'build']],106 '-derivedDataPath', TARGET / 'ios-xcode', 'CODE_SIGNING_ALLOWED=NO', 'build']],
106 missing=None if shutil.which('xcodebuild') else 'needs Xcode'))107 missing=None if shutil.which('xcodebuild') else 'needs Xcode'))
108 ndk, jdk, sdks, rustup, missing = android_toolchain()
109 result.append(dict(
110 name='android', minutes=45, packages=['mobile'], paths=('apps/android/', 'platform/android/'),
111 # build.py cleans its own artifacts, as it reads the API level from the app's manifest.
112 commands=[['python3', 'apps/android/build.py', '--clippy', *(arg for sdk in sdks for arg in ('--sdk', sdk)),
113 '--ndk', ndk, '--java-home', jdk, '--output', TARGET / 'android-apk']],
114 environment={'ANDROID_NDK_HOME': str(ndk), **({'RUSTUP_HOME': str(rustup)} if rustup else {})},
115 missing=missing))
107 result.append(dict(116 result.append(dict(
108 name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',),117 name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',),
109 clean=['sh', CI / 'platform/snow-leopard/cargo.sh', 'clean', '--locked'],118 clean=['sh', CI / 'platform/snow-leopard/cargo.sh', 'clean', '--locked'],
...@@ -115,6 +124,30 @@ def lanes():...@@ -115,6 +124,30 @@ def lanes():
115 return result124 return result
116125
117126
127def android_toolchain():
128 """The NDK, JDK and SDK folders the Android lane builds with, as ANDROID_NDK_HOME,
129 JAVA_HOME and ANDROID_HOME name them or target/android-toolchain holds them; a rustup home
130 for the Android target where the default lacks it; and why the lane can't run, if so."""
131 tools = MAIN / 'target/android-toolchain'
132
133 def found(variable, *patterns):
134 named = os.environ.get(variable)
135 candidates = [Path(named)] if named else [path for pattern in patterns for path in sorted(tools.glob(pattern))]
136 return next((path for path in candidates if path.is_dir()), None)
137
138 ndk = found('ANDROID_NDK_HOME', 'android-ndk-*')
139 jdk = found('JAVA_HOME', 'jdk-*/Contents/Home', 'jdk-*')
140 sdks = [path for path in (os.environ.get('ANDROID_HOME'), Path.home() / 'Library/Android/sdk',
141 tools / 'provider-sdk') if path and Path(path).is_dir()]
142 target = 'aarch64-linux-android'
143 installed = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout
144 rustup = None if target in installed.split() else tools / 'rustup'
145 missing = (f'needs the {target} target' if rustup and not any(rustup.glob(f'toolchains/*/lib/rustlib/{target}'))
146 else 'needs the Android NDK' if not ndk else 'needs a JDK' if not jdk
147 else 'needs the Android SDK' if not sdks else None)
148 return ndk, jdk, sdks, rustup, missing
149
150
118def web_environment():151def web_environment():
119 """A clang and llvm-ar that build SQLite for wasm32, as release_web.py finds them; none152 """A clang and llvm-ar that build SQLite for wasm32, as release_web.py finds them; none
120 where it finds none."""153 where it finds none."""