| author | |
| committer | |
| log | 1acd26a738c5504b6ce961d8065e7149b3035241 |
| tree | 0ff61991238b910ecdc1edbabdc48567ea641e3f |
| parent | d344bc604819cc97912c0f7deccaa3753f7d8c55 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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.55 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 | 19 | | `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw | |
| 20 | 20 | | `crates/ui` | The immediate-mode interface kit and OneNote's chrome controls. Knows nothing of notebooks. | draw | |
| 21 | 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 | 23 | | `tools/`, `corpus/`, `fuzz/` | Lab harnesses (Python, PowerShell, AutoHotkey), the corpus of OneNote-verified files, and a separate cargo-fuzz workspace. | | |
| 24 | 24 | |
| 25 | 25 | Dependencies 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 | 9 | import argparse |
| 10 | 10 | import os |
| 11 | 11 | from pathlib import Path |
| 12 | import re | |
| 12 | 13 | import shutil |
| 13 | 14 | import subprocess |
| 14 | 15 | import sys |
| ... | ... | @@ -18,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[2] |
| 18 | 19 | sys.path.insert(0, str(ROOT / 'tools')) |
| 19 | 20 | from cargo_fresh import clean_local # noqa: E402 |
| 20 | 21 | |
| 21 | API = 26 | |
| 22 | SOURCE = Path(__file__).resolve().parent | |
| 23 | API = int(re.search(r'android:minSdkVersion="(\d+)"', (SOURCE / 'AndroidManifest.xml').read_text())[1]) | |
| 22 | 24 | TARGET = 'aarch64-linux-android' |
| 23 | 25 | |
| 24 | 26 | |
| ... | ... | @@ -32,6 +34,7 @@ def main(): |
| 32 | 34 | parser.add_argument('--ndk', type=Path, required=True) |
| 33 | 35 | parser.add_argument('--java-home', type=Path, required=True) |
| 34 | 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 | 38 | args = parser.parse_args() |
| 36 | 39 | |
| 37 | 40 | def sdk(path): |
| ... | ... | @@ -47,17 +50,21 @@ def main(): |
| 47 | 50 | llvm = ndk / 'toolchains/llvm/prebuilt' / ('darwin-x86_64' if sys.platform == 'darwin' else 'linux-x86_64') / 'bin' |
| 48 | 51 | output = args.output.resolve() |
| 49 | 52 | output.mkdir(parents=True, exist_ok=True) |
| 50 | source = Path(__file__).resolve().parent | |
| 51 | 53 | |
| 52 | 54 | environment = dict(os.environ, ANDROID_NDK_HOME=str(ndk)) |
| 53 | 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 | 60 | # A debug build has wgpu name GPU objects for debuggers, which crashes the emulator's Vulkan driver. |
| 55 | 61 | clean_local(ROOT, '--release', '--target', TARGET, environment=environment) |
| 56 | 62 | run('sh', ROOT / 'platform/android/cargo.sh', API, 'build', '--release', '-p', 'mobile', env=environment) |
| 57 | 63 | library = output / 'libsnowbound.so' |
| 58 | 64 | run(llvm / f'aarch64-linux-android{API}-clang', '-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-fPIC', |
| 59 | 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 | 68 | Path(environment['CARGO_TARGET_DIR']) / TARGET / 'release/libmobile.a', |
| 62 | 69 | '-landroid', '-llog', '-ldl', '-lm', '-o', library) |
| 63 | 70 | # Android 15 devices with 16 KiB pages load only libraries aligned to them. |
| ... | ... | @@ -69,13 +76,13 @@ def main(): |
| 69 | 76 | classes = output / 'classes' |
| 70 | 77 | shutil.rmtree(classes, ignore_errors=True) |
| 71 | 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 | 80 | run(java / 'java', '-cp', tools / 'lib/d8.jar', 'com.android.tools.r8.D8', '--min-api', API, |
| 74 | 81 | '--lib', android, '--output', output, *sorted(classes.rglob('*.class'))) |
| 75 | 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 | 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 | 86 | '-o', unsigned, resources) |
| 80 | 87 | with zipfile.ZipFile(unsigned, 'a') as apk: |
| 81 | 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 | 23 | shared build cache. Clean local package artifacts with `tools/cargo_fresh.py` |
| 24 | 24 | before verifying a different checkout against that cache. |
| 25 | 25 | |
| 26 | This lane checks and builds native code. It does not create an APK or demonstrate | |
| 27 | an Android lifecycle, provider storage, or rendering on a device. The Android | |
| 28 | host must link its final shared library with the selected NDK and verify ELF | |
| 29 | alignment for 16 KB page-size devices. | |
| 26 | This lane checks and builds native code. `apps/android/build.py` builds the app on it: | |
| 27 | it links the shared library with the same NDK, checks its alignment for 16 KB | |
| 28 | page-size devices, and packages and signs the APK. | |
| 30 | 29 | |
| 31 | 30 | Tool selection and prerequisite failures can be checked without an NDK: |
| 32 | 31 |
tools/TESTING.md+1| ... | ... | @@ -30,6 +30,7 @@ cleanup must run serially across checkouts. |
| 30 | 30 | | `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) | |
| 31 | 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 | 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 | 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 | 35 | | `web-js` | Node boundary tests for Live Share browser sockets and storage | |
| 35 | 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 | 43 | """Each lane: its commands, the packages whose dependencies (None for all) and the paths |
| 44 | 44 | that select it under --changed, extra environment, and why it can't run here, if so.""" |
| 45 | 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 | 48 | cross_clippy = ['clippy', '--workspace', '--exclude', 'mobile', '--lib', '--bins', '--all-features', |
| 48 | 49 | '--', '-D', 'warnings'] |
| 49 | 50 | windows = CI / 'platform/windows/cargo.sh' |
| ... | ... | @@ -104,6 +105,14 @@ def lanes(): |
| 104 | 105 | '-configuration', 'Debug', '-destination', 'generic/platform=iOS Simulator', |
| 105 | 106 | '-derivedDataPath', TARGET / 'ios-xcode', 'CODE_SIGNING_ALLOWED=NO', 'build']], |
| 106 | 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 | 116 | result.append(dict( |
| 108 | 117 | name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',), |
| 109 | 118 | clean=['sh', CI / 'platform/snow-leopard/cargo.sh', 'clean', '--locked'], |
| ... | ... | @@ -115,6 +124,30 @@ def lanes(): |
| 115 | 124 | return result |
| 116 | 125 | |
| 117 | 126 | |
| 127 | def 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 | ||
| 118 | 151 | def web_environment(): |
| 119 | 152 | """A clang and llvm-ar that build SQLite for wasm32, as release_web.py finds them; none |
| 120 | 153 | where it finds none.""" |