| 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() |