name: Android Cross Build & Test on: workflow_call: workflow_dispatch: inputs: api: description: 'Android API level' required: false default: '34' type: string permissions: contents: read env: NDK_VERSION: '26.1.10909125' jobs: build-and-test: runs-on: ubuntu-24.04 timeout-minutes: 120 strategy: fail-fast: false matrix: abi: [x86_64] api: ${{ github.event.inputs.api && fromJSON(format('["{0}"]', github.event.inputs.api)) || fromJSON('["34"]') }} steps: # ── Environment setup ────────────────────────────────────────────── - name: Checkout uses: actions/checkout@v7 with: submodules: recursive - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build git ca-certificates python3 \ build-essential make unzip curl - name: Setup ccache uses: hendrikmuhs/ccache-action@v1.2 with: key: android-${{ matrix.abi }} max-size: 300M - name: Setup Java 17 uses: actions/setup-java@v5 with: distribution: temurin java-version: '17' - name: Setup Android SDK uses: android-actions/setup-android@v4 - name: Enable KVM run: sudo chmod 666 /dev/kvm || true - name: Install NDK, emulator and system image shell: bash run: | sdkmanager --install \ "ndk;$NDK_VERSION" \ "platform-tools" \ "platforms;android-${{ matrix.api }}" \ "emulator" # Install x86_64 system image (try variants in order of availability) sdkmanager --install "system-images;android-${{ matrix.api }};google_apis;x86_64" 2>/dev/null || \ sdkmanager --install "system-images;android-${{ matrix.api }};google_apis_playstore;x86_64" 2>/dev/null || \ sdkmanager --install "system-images;android-${{ matrix.api }};default;x86_64" # ── Step 1: build host protoc (using HOST compiler, NOT NDK) ─────── - name: Cache host protoc uses: actions/cache@v6 with: path: build_host key: ${{ runner.os }}-host-protoc-${{ hashFiles('thirdparty/protobuf/**', 'CMakeLists.txt') }} restore-keys: | ${{ runner.os }}-host-protoc- - name: 'Step 1: Build host protoc' shell: bash run: | if [ ! -f "build_host/bin/protoc" ]; then git submodule foreach --recursive 'git stash --include-untracked' 2>/dev/null || true cmake -S . -B build_host \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_TOOLCHAIN_FILE="" \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -G Ninja cmake --build build_host --target protoc --parallel else echo "Using cached host protoc" fi # ── Step 2: cross-compile zvec + tests for Android ───────────────── - name: 'Step 2: Cross-compile zvec and tests' shell: bash env: BUILD_DIR: build_android_${{ matrix.abi }} run: | ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" # Reset thirdparty so the cross toolchain can patch cleanly git submodule foreach --recursive 'git stash --include-untracked' 2>/dev/null || true # Force reconfigure to pick up any cmake changes rm -f "$BUILD_DIR/CMakeCache.txt" cmake -S . -B "$BUILD_DIR" -G Ninja \ -DANDROID_NDK="$ANDROID_NDK_HOME" \ -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ -DANDROID_ABI=${{ matrix.abi }} \ -DANDROID_NATIVE_API_LEVEL=${{ matrix.api }} \ -DANDROID_STL=c++_static \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_PYTHON_BINDINGS=OFF \ -DBUILD_TOOLS=OFF \ -DENABLE_NATIVE=OFF \ -DAUTO_DETECT_ARCH=OFF \ -DENABLE_WERROR=ON \ -DCMAKE_INSTALL_PREFIX="$BUILD_DIR/install" \ -DGLOBAL_CC_PROTOBUF_PROTOC="$GITHUB_WORKSPACE/build_host/bin/protoc" \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache echo "Building all targets..." cmake --build "$BUILD_DIR" --parallel # Discover test targets from ctest metadata echo "Discovering test targets..." TEST_NAMES=() while IFS= read -r line; do name=$(echo "$line" | sed -n 's/.*Test[[:space:]]*#[0-9]*:[[:space:]]*//p') [ -n "$name" ] && TEST_NAMES+=("$name") done < <(cd "$BUILD_DIR" && ctest --show-only 2>/dev/null || true) # Fallback: scan ninja targets for *_test if [ ${#TEST_NAMES[@]} -eq 0 ]; then echo "ctest unavailable, scanning ninja targets..." while IFS= read -r line; do name=$(echo "$line" | sed -n 's/^\([a-zA-Z0-9_]*_test\): .*/\1/p') [ -n "$name" ] && TEST_NAMES+=("$name") done < <(ninja -C "$BUILD_DIR" -t targets all 2>/dev/null || true) fi echo "Building ${#TEST_NAMES[@]} test executables..." ninja -C "$BUILD_DIR" -j$(nproc) "${TEST_NAMES[@]}" # ── Step 3: start emulator ───────────────────────────────────────── - name: 'Step 3: Start Android emulator' shell: bash run: | AVD_NAME="zvec_test_avd" AVD_DIR="$HOME/.android/avd/${AVD_NAME}.avd" AVD_INI="$HOME/.android/avd/${AVD_NAME}.ini" # Find installed system image (same priority as build_android.sh) SYS_IMG="" for variant in google_apis google_apis_playstore default; do candidate="$ANDROID_HOME/system-images/android-${{ matrix.api }}/$variant/x86_64" if [ -d "$candidate" ]; then SYS_IMG="$candidate" break fi done if [ -z "$SYS_IMG" ]; then SYS_IMG=$(find "$ANDROID_HOME/system-images" -type d -name "x86_64" 2>/dev/null | head -1) fi if [ -z "$SYS_IMG" ]; then echo "ERROR: No x86_64 system image found" exit 1 fi echo "System image: $SYS_IMG" # Extract tag from path (e.g. .../google_apis/x86_64 -> google_apis) SYS_TAG=$(basename "$(dirname "$SYS_IMG")") # Create AVD via INI files (faster and more reliable than avdmanager) mkdir -p "$AVD_DIR" cat > "$AVD_INI" << EOAVD avd.ini.encoding=UTF-8 path=${AVD_DIR} path.rel=avd/${AVD_NAME}.avd target=android-${{ matrix.api }} EOAVD cat > "$AVD_DIR/config.ini" << EOCFG AvdId=${AVD_NAME} PlayStore.enabled=false abi.type=x86_64 avd.ini.displayname=${AVD_NAME} avd.ini.encoding=UTF-8 disk.dataPartition.size=8G hw.accelerator.isConfigured=true hw.cpu.arch=x86_64 hw.cpu.ncore=4 hw.lcd.density=420 hw.lcd.height=1920 hw.lcd.width=1080 hw.ramSize=4096 image.sysdir.1=${SYS_IMG}/ tag.display=${SYS_TAG} tag.id=${SYS_TAG} EOCFG echo "Created AVD: $AVD_NAME" # Launch emulator in background $ANDROID_HOME/emulator/emulator -avd "$AVD_NAME" \ -no-window -no-audio -no-boot-anim \ -gpu swiftshader_indirect \ -netdelay none -netspeed full \ -memory 4096 \ -no-snapshot \ -wipe-data & echo "EMULATOR_PID=$!" >> "$GITHUB_ENV" # Wait for device to be reachable adb wait-for-device # Poll for boot completion with timeout echo "Waiting for boot to complete..." TIMEOUT=300 ELAPSED=0 while true; do BOOTED=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n ' || true) if [ "$BOOTED" = "1" ]; then echo "Emulator ready (took ${ELAPSED}s)" break fi if [ $ELAPSED -ge $TIMEOUT ]; then echo "ERROR: Emulator failed to boot within ${TIMEOUT}s" exit 1 fi sleep 3 ELAPSED=$((ELAPSED + 3)) done echo "Device ABI: $(adb shell getprop ro.product.cpu.abi | tr -d '\r')" echo "ABI list : $(adb shell getprop ro.product.cpu.abilist | tr -d '\r')" # ── Step 4: run unit tests on emulator ───────────────────────────── - name: 'Step 4: Run unit tests on emulator' shell: bash env: BUILD_DIR: build_android_${{ matrix.abi }} run: | DEVICE_TEST_DIR="/data/local/tmp/zvec_tests" DEVICE_LIB_DIR="$DEVICE_TEST_DIR/lib" adb shell "mkdir -p $DEVICE_TEST_DIR $DEVICE_LIB_DIR" # Push shared libraries echo "Pushing shared libraries..." SO_COUNT=0 while IFS= read -r so_file; do adb push "$so_file" "$DEVICE_LIB_DIR/$(basename "$so_file")" > /dev/null 2>&1 SO_COUNT=$((SO_COUNT + 1)) done < <(find "$BUILD_DIR/lib" -name "*.so" -type f 2>/dev/null) echo "Pushed $SO_COUNT shared libraries" # Push helper binaries (needed by crash_recovery tests which fork+exec them) echo "Pushing helper binaries..." for helper_name in data_generator collection_optimizer; do helper_path=$(find "$BUILD_DIR" -name "$helper_name" -type f -executable ! -name "*_test" 2>/dev/null | head -1) if [ -n "$helper_path" ]; then adb push "$helper_path" "$DEVICE_TEST_DIR/$helper_name" > /dev/null 2>&1 adb shell "chmod 755 $DEVICE_TEST_DIR/$helper_name" echo " Pushed $helper_name" fi done # Discover test targets from ctest metadata TEST_NAMES=() while IFS= read -r line; do name=$(echo "$line" | sed -n 's/.*Test[[:space:]]*#[0-9]*:[[:space:]]*//p') [ -n "$name" ] && TEST_NAMES+=("$name") done < <(cd "$BUILD_DIR" && ctest --show-only 2>/dev/null || true) if [ ${#TEST_NAMES[@]} -eq 0 ]; then while IFS= read -r line; do name=$(echo "$line" | sed -n 's/^\([a-zA-Z0-9_]*_test\): .*/\1/p') [ -n "$name" ] && TEST_NAMES+=("$name") done < <(ninja -C "$BUILD_DIR" -t targets all 2>/dev/null || true) fi # Collect test binaries TEST_BINS=() for name in "${TEST_NAMES[@]}"; do bin_path=$(find "$BUILD_DIR" -name "$name" -type f -executable 2>/dev/null | head -1) if [ -n "$bin_path" ]; then TEST_BINS+=("$bin_path") else echo "WARNING: binary not found for '$name'" fi done TOTAL=${#TEST_BINS[@]} PASSED=0 FAILED=0 FAILED_NAMES=() IDX=0 echo "Running $TOTAL unit tests on emulator..." for test_bin in "${TEST_BINS[@]}"; do IDX=$((IDX + 1)) test_name=$(basename "$test_bin") device_path="$DEVICE_TEST_DIR/$test_name" # Give each test its own working directory to avoid name collisions WORK_DIR="$DEVICE_TEST_DIR/workdir_${test_name}" echo "" echo "────────────────────────────────────────" echo " [$IDX/$TOTAL] $test_name" echo "────────────────────────────────────────" set +e # Create isolated working directory adb shell "mkdir -p $WORK_DIR" 2>/dev/null # Copy helper binaries into working directory so crash_recovery tests # (which fork+exec data_generator / collection_optimizer) can find them adb shell "for h in $DEVICE_TEST_DIR/data_generator $DEVICE_TEST_DIR/collection_optimizer; do [ -f \$h ] && cp \$h $WORK_DIR/; done" 2>/dev/null # Push test binary adb push "$test_bin" "$device_path" > /dev/null 2>&1 adb shell "chmod 755 $device_path" 2>/dev/null # Run test from its own working directory with LD_LIBRARY_PATH OUTPUT=$(adb shell "cd $WORK_DIR && LD_LIBRARY_PATH=$DEVICE_LIB_DIR $device_path 2>&1; echo EXIT_CODE=\$?" 2>&1) # Extract exit code from the output EXIT_CODE=$(echo "$OUTPUT" | grep -o 'EXIT_CODE=[0-9]*' | tail -1 | cut -d= -f2) set -e # Print test output (without the EXIT_CODE marker) echo "$OUTPUT" | grep -v 'EXIT_CODE=' | sed 's/^/ /' || true if [ "$EXIT_CODE" = "0" ]; then echo " >>> PASSED" PASSED=$((PASSED + 1)) else # Detect "crash-on-exit" pattern: all gtest assertions passed but # process crashed during static destructor teardown (common with c++_static STL) GTEST_PASSED_LINE=$(echo "$OUTPUT" | grep '\[ PASSED \]' | tail -1 || true) GTEST_FAILED_LINE=$(echo "$OUTPUT" | grep '\[ FAILED \]' | head -1 || true) if [ -n "$GTEST_PASSED_LINE" ] && [ -z "$GTEST_FAILED_LINE" ] && \ { [ "$EXIT_CODE" = "139" ] || [ "$EXIT_CODE" = "134" ] || [ "$EXIT_CODE" = "135" ]; }; then echo " >>> PASSED (crash-on-exit ignored, exit=$EXIT_CODE)" PASSED=$((PASSED + 1)) else echo " >>> FAILED (exit=$EXIT_CODE)" FAILED=$((FAILED + 1)) FAILED_NAMES+=("$test_name") fi fi # Clean up binary and working directory to reclaim disk space adb shell "rm -rf $device_path $WORK_DIR" 2>/dev/null || true done echo "" echo "============================================================" echo " Test Summary" echo "============================================================" echo " Total : $TOTAL" echo " Passed : $PASSED" echo " Failed : $FAILED" if [ $FAILED -gt 0 ]; then echo "" echo " Failed tests:" for name in "${FAILED_NAMES[@]}"; do echo " - $name" done fi echo "============================================================" if [ $FAILED -gt 0 ]; then exit 1 fi echo "All tests passed!" # ── Step 5: build and run examples ───────────────────────────────── - name: 'Step 5: Build and run examples' shell: bash env: BUILD_DIR: build_android_${{ matrix.abi }} run: | ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" EXAMPLES_BUILD="examples/c++/build-android-examples-${{ matrix.abi }}" cmake -S examples/c++ -B "$EXAMPLES_BUILD" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ -DANDROID_ABI=${{ matrix.abi }} \ -DANDROID_PLATFORM=android-${{ matrix.api }} \ -DANDROID_STL=c++_static \ -DCMAKE_BUILD_TYPE=Release \ -DHOST_BUILD_DIR="$BUILD_DIR" \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache cmake --build "$EXAMPLES_BUILD" --parallel # Reuse the shared-library directory from Step 4; push again in # case Step 4 was skipped or the directory was cleaned. DEVICE_LIB_DIR="/data/local/tmp/zvec_tests/lib" adb shell "mkdir -p $DEVICE_LIB_DIR" 2>/dev/null || true SO_COUNT=0 while IFS= read -r so_file; do adb push "$so_file" "$DEVICE_LIB_DIR/$(basename "$so_file")" > /dev/null 2>&1 SO_COUNT=$((SO_COUNT + 1)) done < <(find "$BUILD_DIR/lib" -name "*.so" -type f 2>/dev/null) echo "Pushed $SO_COUNT shared libraries to $DEVICE_LIB_DIR" for example in ailego-example core-example db-example; do if [ -f "$EXAMPLES_BUILD/$example" ]; then echo "=== Running $example ===" adb push "$EXAMPLES_BUILD/$example" "/data/local/tmp/$example" > /dev/null 2>&1 adb shell "chmod 755 /data/local/tmp/$example && cd /data/local/tmp && LD_LIBRARY_PATH=$DEVICE_LIB_DIR ./$example" adb shell "rm -f /data/local/tmp/$example" fi done # ── Cleanup ──────────────────────────────────────────────────────── - name: Stop emulator if: always() shell: bash run: | adb emu kill 2>/dev/null || true sleep 2 if [ -n "$EMULATOR_PID" ]; then kill "$EMULATOR_PID" 2>/dev/null || true fi