authorclover caruso <git@paperclover.net> 2026-10-11 12:25:02-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 12:33:31-07:00
log2e662d1cf6a7d5f234931627efa27a95cff70bb0
tree0aa27ed704bd9489eeb2155fc7a8791f5f834bc2
parent33aa2ae19d561e823ca91b18cda8dbb8a4a46597
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

build: add an explicit Android ARM64 Cargo lane

Select the installed NDK and Android API explicitly, use its native compiler for Cargo and bundled C/C++ dependencies, and keep artifacts in the shared cache. Exercise spaced paths and prerequisite failures in ordinary Python discovery. Assisted-by: gpt-6.1-sol

3 files changed, 177 insertions(+), 0 deletions(-)

platform/android/README.md created+38
...@@ -0,0 +1,38 @@
1# Android native cross-compilation
2
3`cargo.sh` builds the ARM64 Rust half with an explicitly installed Android NDK
4and Android API level. Run it from the checkout root. It does not install
5tools, accept licenses, change the
6SDK, or require Java.
7
8```sh
9rustup target add aarch64-linux-android
10ANDROID_NDK_HOME=/path/to/android-ndk-r30 \
11 CARGO_BUILD_JOBS=2 platform/android/cargo.sh 24 check -p mobile
12ANDROID_NDK_HOME=/path/to/android-ndk-r30 \
13 CARGO_BUILD_JOBS=2 platform/android/cargo.sh 24 build -p mobile
14```
15
16API 24 here is a cross-compilation test target; the app's minimum supported
17Android version is not decided by this lane. The chosen NDK must supply
18`aarch64-linux-android<API>-clang`. macOS uses the NDK's universal
19`darwin-x86_64` tools; x86_64 Linux uses `linux-x86_64`.
20
21The native static library is `target/android/aarch64-linux-android/debug/libmobile.a`
22(or `release/libmobile.a` with `--release`). `CARGO_TARGET_DIR` can select another
23shared build cache. Clean local package artifacts with `tools/cargo_fresh.py`
24before verifying a different checkout against that cache.
25
26This lane checks and builds native code. It does not create an APK or demonstrate
27an Android lifecycle, provider storage, or rendering on a device. The Android
28host must link its final shared library with the selected NDK and verify ELF
29alignment for 16 KB page-size devices.
30
31Tool selection and prerequisite failures can be checked without an NDK:
32
33```sh
34python3 -m unittest discover -s tools -p 'test_android_toolchain.py'
35```
36
37NDK compiler conventions:
38https://developer.android.com/ndk/guides/other_build_systems
platform/android/cargo.sh created+35
...@@ -0,0 +1,35 @@
1#!/bin/sh
2# ARM64 Android Cargo lane; the caller chooses the native API level and installed NDK.
3set -eu
4if [ "$#" -lt 2 ]; then
5 echo "usage: ANDROID_NDK_HOME=/path/to/ndk $0 API COMMAND ARGS..." >&2
6 exit 2
7fi
8api=$1 command=$2
9shift 2
10case $api in
11'' | *[!0-9]*) echo "API must be an Android API level number" >&2; exit 2 ;;
12esac
13: "${ANDROID_NDK_HOME:?set ANDROID_NDK_HOME to an installed Android NDK}"
14ndk=$(cd "$ANDROID_NDK_HOME" && pwd)
15case $(uname -s):$(uname -m) in
16Darwin:arm64 | Darwin:x86_64) host=darwin-x86_64 ;;
17Linux:x86_64) host=linux-x86_64 ;;
18*) echo "Android cross-compilation requires an ARM64/x86_64 macOS or x86_64 Linux host" >&2; exit 1 ;;
19esac
20bin=$ndk/toolchains/llvm/prebuilt/$host/bin
21for tool in "aarch64-linux-android$api-clang" "aarch64-linux-android$api-clang++" llvm-ar; do
22 [ -x "$bin/$tool" ] || { echo "NDK tool missing: $bin/$tool" >&2; exit 1; }
23done
24target=aarch64-linux-android
25[ -d "$(rustc --print target-libdir --target "$target")" ] || {
26 echo "install the $target standard library in the selected Rust toolchain first" >&2
27 exit 1
28}
29root=$(cd "$(dirname "$0")/../.." && pwd)
30exec env "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$bin/aarch64-linux-android$api-clang" \
31 "CC_aarch64_linux_android=$bin/aarch64-linux-android$api-clang" \
32 "CXX_aarch64_linux_android=$bin/aarch64-linux-android$api-clang++" \
33 "AR_aarch64_linux_android=$bin/llvm-ar" \
34 "CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$root/target/android}" \
35 cargo "$command" --locked --target "$target" "$@"
tools/test_android_toolchain.py created+104
...@@ -0,0 +1,104 @@
1"""Exercise the Android Cargo lane without changing an installed toolchain."""
2import json
3import os
4from pathlib import Path
5import platform
6import shlex
7import subprocess
8import tempfile
9import unittest
10
11
12LANE = Path(__file__).resolve().parents[1] / 'platform/android/cargo.sh'
13
14
15class AndroidToolchainTests(unittest.TestCase):
16 def setUp(self):
17 self.scratch = tempfile.TemporaryDirectory(prefix='android tools ')
18 self.addCleanup(self.scratch.cleanup)
19 self.root = Path(self.scratch.name)
20 self.bin = self.root / 'bin'
21 self.bin.mkdir()
22 self.std = self.root / 'rust std'
23 self.std.mkdir()
24 host = 'darwin-x86_64' if platform.system() == 'Darwin' else 'linux-x86_64'
25 self.ndk = self.root / 'android ndk'
26 self.tools = self.ndk / 'toolchains/llvm/prebuilt' / host / 'bin'
27 self.tools.mkdir(parents=True)
28 for name in ('aarch64-linux-android24-clang', 'aarch64-linux-android24-clang++', 'llvm-ar'):
29 tool = self.tools / name
30 tool.write_text('#!/bin/sh\nexit 0\n')
31 tool.chmod(0o755)
32 rustc = self.bin / 'rustc'
33 rustc.write_text('#!/bin/sh\nprintf "%s\\n" ' + shlex.quote(str(self.std)) + '\n')
34 rustc.chmod(0o755)
35 cargo = self.bin / 'cargo'
36 cargo.write_text('''#!/usr/bin/env python3
37import json, os, sys
38print(json.dumps({'arguments': sys.argv[1:], 'environment': {
39 key: value for key, value in os.environ.items()
40 if key.startswith(('CC_', 'CXX_', 'AR_', 'CARGO_TARGET'))}}))
41sys.exit(int(os.environ.get('FAKE_CARGO_EXIT', '0')))
42''')
43 cargo.chmod(0o755)
44 self.env = {**os.environ, 'PATH': str(self.bin) + os.pathsep + os.environ['PATH'],
45 'ANDROID_NDK_HOME': str(self.ndk)}
46 self.env.pop('CARGO_TARGET_DIR', None)
47
48 def run_lane(self, *arguments):
49 return subprocess.run([str(LANE), *arguments], env=self.env,
50 text=True, capture_output=True, timeout=10)
51
52 def test_explicit_api_and_tools_with_spaces_reach_cargo(self):
53 result = self.run_lane('24', 'check', '-p', 'mobile', '--config', 'build.jobs = 2')
54 self.assertEqual(result.returncode, 0, result.stderr)
55 record = json.loads(result.stdout)
56 self.assertEqual(record['arguments'], [
57 'check', '--locked', '--target', 'aarch64-linux-android', '-p', 'mobile', '--config', 'build.jobs = 2'])
58 environment = record['environment']
59 self.assertEqual(environment['CC_aarch64_linux_android'],
60 str(self.tools / 'aarch64-linux-android24-clang'))
61 self.assertEqual(environment['CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER'],
62 environment['CC_aarch64_linux_android'])
63 self.assertEqual(environment['CXX_aarch64_linux_android'],
64 str(self.tools / 'aarch64-linux-android24-clang++'))
65 self.assertEqual(environment['AR_aarch64_linux_android'], str(self.tools / 'llvm-ar'))
66 self.assertEqual(environment['CARGO_TARGET_DIR'], str(LANE.parents[2] / 'target/android'))
67
68 def test_shared_cache_override_and_cargo_failure_are_preserved(self):
69 self.env.update(CARGO_TARGET_DIR=str(self.root / 'shared cache'), FAKE_CARGO_EXIT='37')
70 result = self.run_lane('24', 'build', '-p', 'mobile')
71 self.assertEqual(result.returncode, 37)
72 self.assertEqual(json.loads(result.stdout)['environment']['CARGO_TARGET_DIR'],
73 self.env['CARGO_TARGET_DIR'])
74
75 def test_api_is_required_and_numeric(self):
76 for arguments in ((), ('24',), ('24; touch sentinel', 'build')):
77 with self.subTest(arguments=arguments):
78 result = self.run_lane(*arguments)
79 self.assertEqual(result.returncode, 2)
80 self.assertEqual(result.stdout, '')
81
82 def test_ndk_is_explicit(self):
83 self.env.pop('ANDROID_NDK_HOME')
84 result = self.run_lane('24', 'build')
85 self.assertNotEqual(result.returncode, 0)
86 self.assertIn('set ANDROID_NDK_HOME', result.stderr)
87 self.assertEqual(result.stdout, '')
88
89 def test_missing_api_compiler_stops_before_cargo(self):
90 result = self.run_lane('9999', 'check')
91 self.assertNotEqual(result.returncode, 0)
92 self.assertIn('NDK tool missing:', result.stderr)
93 self.assertEqual(result.stdout, '')
94
95 def test_missing_rust_target_stops_before_cargo(self):
96 self.std.rmdir()
97 result = self.run_lane('24', 'build')
98 self.assertNotEqual(result.returncode, 0)
99 self.assertIn('install the aarch64-linux-android standard library', result.stderr)
100 self.assertEqual(result.stdout, '')
101
102
103if __name__ == '__main__':
104 unittest.main()