| author | |
| committer | |
| log | 2e662d1cf6a7d5f234931627efa27a95cff70bb0 |
| tree | 0aa27ed704bd9489eeb2155fc7a8791f5f834bc2 |
| parent | 33aa2ae19d561e823ca91b18cda8dbb8a4a46597 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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-sol3 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 | ||
| 4 | and Android API level. Run it from the checkout root. It does not install | ||
| 5 | tools, accept licenses, change the | ||
| 6 | SDK, or require Java. | ||
| 7 | |||
| 8 | ```sh | ||
| 9 | rustup target add aarch64-linux-android | ||
| 10 | ANDROID_NDK_HOME=/path/to/android-ndk-r30 \ | ||
| 11 | CARGO_BUILD_JOBS=2 platform/android/cargo.sh 24 check -p mobile | ||
| 12 | ANDROID_NDK_HOME=/path/to/android-ndk-r30 \ | ||
| 13 | CARGO_BUILD_JOBS=2 platform/android/cargo.sh 24 build -p mobile | ||
| 14 | ``` | ||
| 15 | |||
| 16 | API 24 here is a cross-compilation test target; the app's minimum supported | ||
| 17 | Android 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 | |||
| 21 | The 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 | ||
| 23 | shared build cache. Clean local package artifacts with `tools/cargo_fresh.py` | ||
| 24 | before verifying a different checkout against that cache. | ||
| 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. | ||
| 30 | |||
| 31 | Tool selection and prerequisite failures can be checked without an NDK: | ||
| 32 | |||
| 33 | ```sh | ||
| 34 | python3 -m unittest discover -s tools -p 'test_android_toolchain.py' | ||
| 35 | ``` | ||
| 36 | |||
| 37 | NDK compiler conventions: | ||
| 38 | https://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. | ||
| 3 | set -eu | ||
| 4 | if [ "$#" -lt 2 ]; then | ||
| 5 | echo "usage: ANDROID_NDK_HOME=/path/to/ndk $0 API COMMAND ARGS..." >&2 | ||
| 6 | exit 2 | ||
| 7 | fi | ||
| 8 | api=$1 command=$2 | ||
| 9 | shift 2 | ||
| 10 | case $api in | ||
| 11 | '' | *[!0-9]*) echo "API must be an Android API level number" >&2; exit 2 ;; | ||
| 12 | esac | ||
| 13 | : "${ANDROID_NDK_HOME:?set ANDROID_NDK_HOME to an installed Android NDK}" | ||
| 14 | ndk=$(cd "$ANDROID_NDK_HOME" && pwd) | ||
| 15 | case $(uname -s):$(uname -m) in | ||
| 16 | Darwin:arm64 | Darwin:x86_64) host=darwin-x86_64 ;; | ||
| 17 | Linux: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 ;; | ||
| 19 | esac | ||
| 20 | bin=$ndk/toolchains/llvm/prebuilt/$host/bin | ||
| 21 | for 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; } | ||
| 23 | done | ||
| 24 | target=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 | } | ||
| 29 | root=$(cd "$(dirname "$0")/../.." && pwd) | ||
| 30 | exec 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.""" | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | from pathlib import Path | ||
| 5 | import platform | ||
| 6 | import shlex | ||
| 7 | import subprocess | ||
| 8 | import tempfile | ||
| 9 | import unittest | ||
| 10 | |||
| 11 | |||
| 12 | LANE = Path(__file__).resolve().parents[1] / 'platform/android/cargo.sh' | ||
| 13 | |||
| 14 | |||
| 15 | class 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 | ||
| 37 | import json, os, sys | ||
| 38 | print(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'))}})) | ||
| 41 | sys.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 | |||
| 103 | if __name__ == '__main__': | ||
| 104 | unittest.main() | ||