diff --git a/platform/android/README.md b/platform/android/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bcd90319d07fffd3d6ca06493b1b6448b202aad4 --- /dev/null +++ b/platform/android/README.md @@ -0,0 +1,38 @@ +# Android native cross-compilation + +`cargo.sh` builds the ARM64 Rust half with an explicitly installed Android NDK +and Android API level. Run it from the checkout root. It does not install +tools, accept licenses, change the +SDK, or require Java. + +```sh +rustup target add aarch64-linux-android +ANDROID_NDK_HOME=/path/to/android-ndk-r30 \ + CARGO_BUILD_JOBS=2 platform/android/cargo.sh 24 check -p mobile +ANDROID_NDK_HOME=/path/to/android-ndk-r30 \ + CARGO_BUILD_JOBS=2 platform/android/cargo.sh 24 build -p mobile +``` + +API 24 here is a cross-compilation test target; the app's minimum supported +Android version is not decided by this lane. The chosen NDK must supply +`aarch64-linux-android-clang`. macOS uses the NDK's universal +`darwin-x86_64` tools; x86_64 Linux uses `linux-x86_64`. + +The native static library is `target/android/aarch64-linux-android/debug/libmobile.a` +(or `release/libmobile.a` with `--release`). `CARGO_TARGET_DIR` can select another +shared build cache. Clean local package artifacts with `tools/cargo_fresh.py` +before verifying a different checkout against that cache. + +This lane checks and builds native code. It does not create an APK or demonstrate +an Android lifecycle, provider storage, or rendering on a device. The Android +host must link its final shared library with the selected NDK and verify ELF +alignment for 16 KB page-size devices. + +Tool selection and prerequisite failures can be checked without an NDK: + +```sh +python3 -m unittest discover -s tools -p 'test_android_toolchain.py' +``` + +NDK compiler conventions: +https://developer.android.com/ndk/guides/other_build_systems diff --git a/platform/android/cargo.sh b/platform/android/cargo.sh new file mode 100755 index 0000000000000000000000000000000000000000..03ef71b026e46fab871002fa9c88960a7e78a260 --- /dev/null +++ b/platform/android/cargo.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# ARM64 Android Cargo lane; the caller chooses the native API level and installed NDK. +set -eu +if [ "$#" -lt 2 ]; then + echo "usage: ANDROID_NDK_HOME=/path/to/ndk $0 API COMMAND ARGS..." >&2 + exit 2 +fi +api=$1 command=$2 +shift 2 +case $api in +'' | *[!0-9]*) echo "API must be an Android API level number" >&2; exit 2 ;; +esac +: "${ANDROID_NDK_HOME:?set ANDROID_NDK_HOME to an installed Android NDK}" +ndk=$(cd "$ANDROID_NDK_HOME" && pwd) +case $(uname -s):$(uname -m) in +Darwin:arm64 | Darwin:x86_64) host=darwin-x86_64 ;; +Linux:x86_64) host=linux-x86_64 ;; +*) echo "Android cross-compilation requires an ARM64/x86_64 macOS or x86_64 Linux host" >&2; exit 1 ;; +esac +bin=$ndk/toolchains/llvm/prebuilt/$host/bin +for tool in "aarch64-linux-android$api-clang" "aarch64-linux-android$api-clang++" llvm-ar; do + [ -x "$bin/$tool" ] || { echo "NDK tool missing: $bin/$tool" >&2; exit 1; } +done +target=aarch64-linux-android +[ -d "$(rustc --print target-libdir --target "$target")" ] || { + echo "install the $target standard library in the selected Rust toolchain first" >&2 + exit 1 +} +root=$(cd "$(dirname "$0")/../.." && pwd) +exec env "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$bin/aarch64-linux-android$api-clang" \ + "CC_aarch64_linux_android=$bin/aarch64-linux-android$api-clang" \ + "CXX_aarch64_linux_android=$bin/aarch64-linux-android$api-clang++" \ + "AR_aarch64_linux_android=$bin/llvm-ar" \ + "CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$root/target/android}" \ + cargo "$command" --locked --target "$target" "$@" diff --git a/tools/test_android_toolchain.py b/tools/test_android_toolchain.py new file mode 100644 index 0000000000000000000000000000000000000000..3f90dab73867b3bef60a3f404799fd6eeab70016 --- /dev/null +++ b/tools/test_android_toolchain.py @@ -0,0 +1,104 @@ +"""Exercise the Android Cargo lane without changing an installed toolchain.""" +import json +import os +from pathlib import Path +import platform +import shlex +import subprocess +import tempfile +import unittest + + +LANE = Path(__file__).resolve().parents[1] / 'platform/android/cargo.sh' + + +class AndroidToolchainTests(unittest.TestCase): + def setUp(self): + self.scratch = tempfile.TemporaryDirectory(prefix='android tools ') + self.addCleanup(self.scratch.cleanup) + self.root = Path(self.scratch.name) + self.bin = self.root / 'bin' + self.bin.mkdir() + self.std = self.root / 'rust std' + self.std.mkdir() + host = 'darwin-x86_64' if platform.system() == 'Darwin' else 'linux-x86_64' + self.ndk = self.root / 'android ndk' + self.tools = self.ndk / 'toolchains/llvm/prebuilt' / host / 'bin' + self.tools.mkdir(parents=True) + for name in ('aarch64-linux-android24-clang', 'aarch64-linux-android24-clang++', 'llvm-ar'): + tool = self.tools / name + tool.write_text('#!/bin/sh\nexit 0\n') + tool.chmod(0o755) + rustc = self.bin / 'rustc' + rustc.write_text('#!/bin/sh\nprintf "%s\\n" ' + shlex.quote(str(self.std)) + '\n') + rustc.chmod(0o755) + cargo = self.bin / 'cargo' + cargo.write_text('''#!/usr/bin/env python3 +import json, os, sys +print(json.dumps({'arguments': sys.argv[1:], 'environment': { + key: value for key, value in os.environ.items() + if key.startswith(('CC_', 'CXX_', 'AR_', 'CARGO_TARGET'))}})) +sys.exit(int(os.environ.get('FAKE_CARGO_EXIT', '0'))) +''') + cargo.chmod(0o755) + self.env = {**os.environ, 'PATH': str(self.bin) + os.pathsep + os.environ['PATH'], + 'ANDROID_NDK_HOME': str(self.ndk)} + self.env.pop('CARGO_TARGET_DIR', None) + + def run_lane(self, *arguments): + return subprocess.run([str(LANE), *arguments], env=self.env, + text=True, capture_output=True, timeout=10) + + def test_explicit_api_and_tools_with_spaces_reach_cargo(self): + result = self.run_lane('24', 'check', '-p', 'mobile', '--config', 'build.jobs = 2') + self.assertEqual(result.returncode, 0, result.stderr) + record = json.loads(result.stdout) + self.assertEqual(record['arguments'], [ + 'check', '--locked', '--target', 'aarch64-linux-android', '-p', 'mobile', '--config', 'build.jobs = 2']) + environment = record['environment'] + self.assertEqual(environment['CC_aarch64_linux_android'], + str(self.tools / 'aarch64-linux-android24-clang')) + self.assertEqual(environment['CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER'], + environment['CC_aarch64_linux_android']) + self.assertEqual(environment['CXX_aarch64_linux_android'], + str(self.tools / 'aarch64-linux-android24-clang++')) + self.assertEqual(environment['AR_aarch64_linux_android'], str(self.tools / 'llvm-ar')) + self.assertEqual(environment['CARGO_TARGET_DIR'], str(LANE.parents[2] / 'target/android')) + + def test_shared_cache_override_and_cargo_failure_are_preserved(self): + self.env.update(CARGO_TARGET_DIR=str(self.root / 'shared cache'), FAKE_CARGO_EXIT='37') + result = self.run_lane('24', 'build', '-p', 'mobile') + self.assertEqual(result.returncode, 37) + self.assertEqual(json.loads(result.stdout)['environment']['CARGO_TARGET_DIR'], + self.env['CARGO_TARGET_DIR']) + + def test_api_is_required_and_numeric(self): + for arguments in ((), ('24',), ('24; touch sentinel', 'build')): + with self.subTest(arguments=arguments): + result = self.run_lane(*arguments) + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stdout, '') + + def test_ndk_is_explicit(self): + self.env.pop('ANDROID_NDK_HOME') + result = self.run_lane('24', 'build') + self.assertNotEqual(result.returncode, 0) + self.assertIn('set ANDROID_NDK_HOME', result.stderr) + self.assertEqual(result.stdout, '') + + def test_missing_api_compiler_stops_before_cargo(self): + result = self.run_lane('9999', 'check') + self.assertNotEqual(result.returncode, 0) + self.assertIn('NDK tool missing:', result.stderr) + self.assertEqual(result.stdout, '') + + def test_missing_rust_target_stops_before_cargo(self): + self.std.rmdir() + result = self.run_lane('24', 'build') + self.assertNotEqual(result.returncode, 0) + self.assertIn('install the aarch64-linux-android standard library', result.stderr) + self.assertEqual(result.stdout, '') + + +if __name__ == '__main__': + unittest.main()