From 6e80e2346024c8115fcb6017ff5458f8cd421d40 Mon Sep 17 00:00:00 2001 From: leaiss Date: Wed, 27 May 2026 13:41:58 -0700 Subject: [PATCH 1/5] cube_handle_vk_android: stable log sentinel after xrCreateInstance success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single distinctive log line after xrCreateInstance returns XR_SUCCESS. The CI emulator smoke test (next commit) greps logcat for this exact string to assert the full broker -> runtime dlopen -> plug-in load -> xrCreateInstance chain reached success — same chain PR #343's preload regression broke. Tag intentionally specific so the grep can't false-match other log output. Do not rename without updating the CI workflow. --- test_apps/cube_handle_vk_android/src/main/cpp/main.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test_apps/cube_handle_vk_android/src/main/cpp/main.cpp b/test_apps/cube_handle_vk_android/src/main/cpp/main.cpp index 4bbc9dd64..31b3161b0 100644 --- a/test_apps/cube_handle_vk_android/src/main/cpp/main.cpp +++ b/test_apps/cube_handle_vk_android/src/main/cpp/main.cpp @@ -200,6 +200,13 @@ create_instance(struct android_app *app) return false; } + /* Stable sentinel for CI emulator smoke test (build-android.yml). + * Tag intentionally distinctive so a single grep over logcat + * confirms the full broker → runtime → plug-in chain reached + * xrCreateInstance success. Do NOT rename without updating the + * CI workflow. */ + LOGI("ANDROID_POC_SENTINEL xrCreateInstance=XR_SUCCESS"); + XrInstanceProperties props = {}; props.type = XR_TYPE_INSTANCE_PROPERTIES; res = xrGetInstanceProperties(g_instance, &props); From e390c66860c8fefd415f2597f4a27d0b6e0f999e Mon Sep 17 00:00:00 2001 From: leaiss Date: Wed, 27 May 2026 14:08:43 -0700 Subject: [PATCH 2/5] scripts: android-smoketest.sh for local end-to-end POC validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-cmd local replacement for the CI emulator gate we backed out: - Boots emulator if no device attached - Builds plug-in + installs 16 transitive .so into runtime tree - Builds runtime APK + test app APK - Installs both, launches test app - Greps logcat for the ANDROID_POC_SENTINEL line, with 60s timeout - Returns 0 on pass / 1 on hard fail / 2 on timeout / 3 on missing prereq Validated locally end-to-end: passes in ~6s on Android-36 emulator. Why local instead of CI: the CI smoke test needs to clone CNSDK from LeiaInc/leiainc.github.io which the workflow's GITHUB_TOKEN can't read (App-scoped to DisplayXR), and anonymous clones are rejected by github.com's auth path. Cross-org access needs a PAT secret or vendoring CNSDK — not blocking on either for now. Devs running this already have CNSDK on disk. --- scripts/android-smoketest.sh | 186 +++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 scripts/android-smoketest.sh diff --git a/scripts/android-smoketest.sh b/scripts/android-smoketest.sh new file mode 100644 index 000000000..b4fbbbb3b --- /dev/null +++ b/scripts/android-smoketest.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# Android emulator smoke test: end-to-end validation that the runtime +# + leia-plugin chain reaches xrCreateInstance success on a real +# Android device or emulator. Catches regressions like PR #343's +# preload break, which compiled fine but broke at load time. +# +# Run from the runtime repo root: +# +# scripts/android-smoketest.sh +# +# Optional env vars: +# PLUGIN_DIR Plug-in repo path (default: ../displayxr-leia-plugin) +# PLUGIN_BRANCH Branch to check out in the plug-in repo (default: don't touch) +# CNSDK_ROOT CNSDK extracted dir (default: ./cnsdk) +# AVD AVD name (default: Medium_Phone_API_36) +# ANDROID_SDK_ROOT Android SDK (default: $LOCALAPPDATA/Android/Sdk on Windows +# or $HOME/Android/Sdk on Linux/macOS) +# SKIP_EMULATOR 1 to use an already-attached device, skip emulator boot +# SMOKE_TIMEOUT seconds to wait for the sentinel (default: 60) +# +# Exit codes: +# 0 — sentinel `ANDROID_POC_SENTINEL xrCreateInstance=XR_SUCCESS` seen +# 1 — hard failure (FATAL / XR_ERROR_INITIALIZATION_FAILED / RUNTIME_UNAVAILABLE) +# 2 — sentinel not seen within timeout +# 3 — prerequisite missing (plug-in repo, CNSDK, adb, etc.) + +set -euo pipefail + +# ----- defaults + path resolution --------------------------------------------- + +RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$RUNTIME_ROOT" + +PLUGIN_DIR="${PLUGIN_DIR:-$RUNTIME_ROOT/../displayxr-leia-plugin}" +CNSDK_ROOT="${CNSDK_ROOT:-$RUNTIME_ROOT/cnsdk}" +AVD="${AVD:-Medium_Phone_API_36}" +SMOKE_TIMEOUT="${SMOKE_TIMEOUT:-60}" + +if [[ -z "${ANDROID_SDK_ROOT:-}" ]]; then + if [[ -n "${LOCALAPPDATA:-}" && -d "$LOCALAPPDATA/Android/Sdk" ]]; then + ANDROID_SDK_ROOT="$LOCALAPPDATA/Android/Sdk" + elif [[ -d "$HOME/Android/Sdk" ]]; then + ANDROID_SDK_ROOT="$HOME/Android/Sdk" + elif [[ -d "$HOME/Library/Android/sdk" ]]; then + ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" + else + echo "ERROR: ANDROID_SDK_ROOT unset and no SDK found at common paths." >&2 + exit 3 + fi +fi + +# Pick adb / emulator binary (Windows .exe vs Linux/macOS) +if [[ -x "$ANDROID_SDK_ROOT/platform-tools/adb.exe" ]]; then + ADB="$ANDROID_SDK_ROOT/platform-tools/adb.exe" +elif [[ -x "$ANDROID_SDK_ROOT/platform-tools/adb" ]]; then + ADB="$ANDROID_SDK_ROOT/platform-tools/adb" +else + echo "ERROR: adb not found under $ANDROID_SDK_ROOT/platform-tools/" >&2 + exit 3 +fi + +if [[ -x "$ANDROID_SDK_ROOT/emulator/emulator.exe" ]]; then + EMULATOR="$ANDROID_SDK_ROOT/emulator/emulator.exe" +elif [[ -x "$ANDROID_SDK_ROOT/emulator/emulator" ]]; then + EMULATOR="$ANDROID_SDK_ROOT/emulator/emulator" +else + EMULATOR="" +fi + +# ----- prereq checks ---------------------------------------------------------- + +if [[ ! -d "$PLUGIN_DIR/scripts" || ! -f "$PLUGIN_DIR/scripts/build-android.sh" ]]; then + echo "ERROR: plug-in repo not found at $PLUGIN_DIR" >&2 + echo " clone DisplayXR/displayxr-leia-plugin and set PLUGIN_DIR env var" >&2 + exit 3 +fi + +if [[ ! -d "$CNSDK_ROOT/include" ]]; then + echo "ERROR: CNSDK not extracted at $CNSDK_ROOT (no include/ dir)" >&2 + echo " download cnsdk-android-0.7.28.zip and extract to $CNSDK_ROOT" >&2 + exit 3 +fi + +# ----- emulator boot ---------------------------------------------------------- + +ensure_device() { + if [[ "${SKIP_EMULATOR:-0}" == "1" ]]; then + return + fi + local attached + attached=$("$ADB" devices | awk 'NR>1 && $2=="device"{print $1}' | head -1 || true) + if [[ -n "$attached" ]]; then + echo "[smoketest] device already attached: $attached" + return + fi + if [[ -z "$EMULATOR" ]]; then + echo "ERROR: no device attached and emulator binary not found." >&2 + exit 3 + fi + echo "[smoketest] launching emulator $AVD" + "$EMULATOR" -avd "$AVD" -no-snapshot-load -no-audio \ + -gpu swiftshader_indirect > /tmp/smoketest-emulator.log 2>&1 & + "$ADB" wait-for-device + "$ADB" shell 'while [ "$(getprop sys.boot_completed | tr -d \\r)" != "1" ]; do sleep 2; done' + echo "[smoketest] emulator booted" +} + +# ----- build chain ------------------------------------------------------------ + +build_plugin_and_install_jnilibs() { + echo "[smoketest] building plug-in + installing transitive jniLibs" + ( + cd "$PLUGIN_DIR" + CNSDK_ROOT="$CNSDK_ROOT" \ + DXR_RUNTIME_SOURCE_DIR="$RUNTIME_ROOT" \ + bash scripts/build-android.sh install-runtime-jnilibs + ) + local jni_dir="$RUNTIME_ROOT/src/xrt/targets/openxr_android/src/main/jniLibs/arm64-v8a" + local count + count=$(ls "$jni_dir" 2>/dev/null | wc -l) + if (( count < 5 )); then + echo "ERROR: expected >=5 transitive .so in $jni_dir, got $count" >&2 + exit 3 + fi + echo "[smoketest] $count .so files in jniLibs/arm64-v8a/" +} + +GRADLEW="./gradlew" +if [[ -x "./gradlew.bat" ]] && command -v cmd.exe >/dev/null 2>&1; then + GRADLEW="./gradlew.bat" +fi + +build_apks() { + echo "[smoketest] building runtime APK + test app APK" + "$GRADLEW" :src:xrt:targets:openxr_android:assembleInProcessDebug --console=plain --rerun-tasks + "$GRADLEW" :test_apps:cube_handle_vk_android:assembleDebug --console=plain +} + +# ----- install + launch + grep ----------------------------------------------- + +RUNTIME_APK="$RUNTIME_ROOT/src/xrt/targets/openxr_android/build/outputs/apk/inProcess/debug/openxr_android-inProcess-debug.apk" +TEST_APK="$RUNTIME_ROOT/test_apps/cube_handle_vk_android/build/outputs/apk/debug/cube_handle_vk_android-debug.apk" +TEST_PKG="com.displayxr.cube_handle_vk_android" +SENTINEL="ANDROID_POC_SENTINEL xrCreateInstance=XR_SUCCESS" + +smoke_test() { + [[ -f "$RUNTIME_APK" ]] || { echo "ERROR: $RUNTIME_APK not built" >&2; exit 3; } + [[ -f "$TEST_APK" ]] || { echo "ERROR: $TEST_APK not built" >&2; exit 3; } + + echo "[smoketest] installing runtime APK" + "$ADB" install -r "$RUNTIME_APK" > /dev/null + echo "[smoketest] installing test app APK" + "$ADB" install -r "$TEST_APK" > /dev/null + + "$ADB" shell setprop debug.dxr.hw.verbose 1 > /dev/null + "$ADB" shell am force-stop "$TEST_PKG" > /dev/null + "$ADB" logcat -c + "$ADB" shell am start -n "$TEST_PKG/android.app.NativeActivity" > /dev/null + + echo "[smoketest] watching logcat for sentinel (timeout ${SMOKE_TIMEOUT}s)" + local end=$(( $(date +%s) + SMOKE_TIMEOUT )) + while (( $(date +%s) < end )); do + if "$ADB" logcat -d | grep -q "$SENTINEL"; then + echo "[smoketest] PASS: sentinel found" + "$ADB" logcat -d | grep "$SENTINEL" | head -1 + return 0 + fi + if "$ADB" logcat -d | grep -qE 'AndroidRuntime: FATAL|XR_ERROR_INITIALIZATION_FAILED|XR_ERROR_RUNTIME_UNAVAILABLE'; then + echo "[smoketest] FAIL: hit a hard error before sentinel" + "$ADB" logcat -d | grep -E 'AndroidRuntime: FATAL|XR_ERROR_|monado\.' | tail -20 + return 1 + fi + sleep 2 + done + echo "[smoketest] FAIL: sentinel not seen within ${SMOKE_TIMEOUT}s" + "$ADB" logcat -d | tail -30 + return 2 +} + +# ----- main ------------------------------------------------------------------- + +ensure_device +build_plugin_and_install_jnilibs +build_apks +smoke_test From 9d75037d0adc96e9e736797b1ef459e04200bd64 Mon Sep 17 00:00:00 2001 From: leaiss Date: Tue, 2 Jun 2026 09:06:05 -0700 Subject: [PATCH 3/5] android(smoketest): launch via LAUNCHER intent, not hard-coded NativeActivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test app's entry point became a Kotlin .MainActivity wrapper (#359: requests CAMERA at onCreate, then hands off to NativeActivity). The hard-coded `am start -n $PKG/android.app.NativeActivity` then fails with "Activity class does not exist" — a cross-stack break only visible when the smoke-test (this stack) and the permission wrapper (#350/#359 stack) are combined. Resolve the launcher activity via the LAUNCHER intent so the smoke test survives activity-class renames. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/android-smoketest.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/android-smoketest.sh b/scripts/android-smoketest.sh index b4fbbbb3b..7caaf46a6 100644 --- a/scripts/android-smoketest.sh +++ b/scripts/android-smoketest.sh @@ -156,7 +156,11 @@ smoke_test() { "$ADB" shell setprop debug.dxr.hw.verbose 1 > /dev/null "$ADB" shell am force-stop "$TEST_PKG" > /dev/null "$ADB" logcat -c - "$ADB" shell am start -n "$TEST_PKG/android.app.NativeActivity" > /dev/null + # Launch via the LAUNCHER intent rather than a hard-coded activity class: + # the test app's entry point is a Kotlin .MainActivity wrapper (it requests + # CAMERA before handing off to NativeActivity), and the class name has moved + # before. Resolving the launcher activity keeps this rename-proof. + "$ADB" shell monkey -p "$TEST_PKG" -c android.intent.category.LAUNCHER 1 > /dev/null echo "[smoketest] watching logcat for sentinel (timeout ${SMOKE_TIMEOUT}s)" local end=$(( $(date +%s) + SMOKE_TIMEOUT )) From 4aa12b67d66d885a4ffe3354776f1f4bfe141ef4 Mon Sep 17 00:00:00 2001 From: leaiss Date: Tue, 2 Jun 2026 09:47:53 -0700 Subject: [PATCH 4/5] android(smoketest): fail fast + bound boot + assert ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-pass robustness fixes so a hardware/CI run reports the true cause instead of always-"sentinel timeout": - bound the emulator boot wait with EMULATOR_BOOT_TIMEOUT (default 300s); bare wait-for-device / boot_completed poll could hang forever. - assert_abi(): arm64-v8a must be in the device's cpu.abilist before building (the test app is arm64-only; an x86_64 AVD otherwise dies late with a confusing INSTALL_FAILED_NO_MATCHING_ABIS). - after launch, confirm the app process actually started (monkey returns 0 even on a no-op / instant crash) — fail with return 1, not a timeout. - single logcat capture per poll; distinguish 'adb died' (return 3) from 'not seen yet', and detect the process vanishing mid-bring-up (crash → 1). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/android-smoketest.sh | 71 ++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/scripts/android-smoketest.sh b/scripts/android-smoketest.sh index 7caaf46a6..47664c1ad 100644 --- a/scripts/android-smoketest.sh +++ b/scripts/android-smoketest.sh @@ -101,11 +101,41 @@ ensure_device() { echo "[smoketest] launching emulator $AVD" "$EMULATOR" -avd "$AVD" -no-snapshot-load -no-audio \ -gpu swiftshader_indirect > /tmp/smoketest-emulator.log 2>&1 & - "$ADB" wait-for-device - "$ADB" shell 'while [ "$(getprop sys.boot_completed | tr -d \\r)" != "1" ]; do sleep 2; done' + # Bound the boot wait with our own deadline — bare `adb wait-for-device` + # and the boot_completed poll can hang forever if the emulator never comes + # up, stalling the whole run until a job-level timeout. + local boot_deadline=$(( $(date +%s) + ${EMULATOR_BOOT_TIMEOUT:-300} )) + until [[ "$("$ADB" get-state 2>/dev/null | tr -d '\r')" == "device" ]]; do + (( $(date +%s) < boot_deadline )) || { + echo "ERROR: emulator did not attach within ${EMULATOR_BOOT_TIMEOUT:-300}s (see /tmp/smoketest-emulator.log)" >&2 + exit 3 + } + sleep 2 + done + until [[ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do + (( $(date +%s) < boot_deadline )) || { + echo "ERROR: emulator attached but sys.boot_completed != 1 within deadline" >&2 + exit 3 + } + sleep 2 + done echo "[smoketest] emulator booted" } +# Fail loud on an ABI mismatch (arm64-only APK on an x86_64 emulator yields a +# confusing INSTALL_FAILED_NO_MATCHING_ABIS) before spending minutes building. +assert_abi() { + local abilist + abilist=$("$ADB" shell getprop ro.product.cpu.abilist 2>/dev/null | tr -d '\r') + if [[ "$abilist" != *arm64-v8a* ]]; then + echo "ERROR: device ABI list '$abilist' has no arm64-v8a — the test app is" >&2 + echo " built arm64-v8a only (test_apps/.../build.gradle). Use an arm64 AVD" >&2 + echo " or a Lume Pad. Aborting before the build." >&2 + exit 3 + fi + echo "[smoketest] device ABI list: $abilist (arm64-v8a present)" +} + # ----- build chain ------------------------------------------------------------ build_plugin_and_install_jnilibs() { @@ -162,29 +192,56 @@ smoke_test() { # before. Resolving the launcher activity keeps this rename-proof. "$ADB" shell monkey -p "$TEST_PKG" -c android.intent.category.LAUNCHER 1 > /dev/null + # monkey returns 0 even if the activity never resolved or the app crashed on + # resume. Confirm the process actually came up so an instant crash fails fast + # (return 1) instead of masquerading as a sentinel timeout (return 2). + local proc_deadline=$(( $(date +%s) + 15 )) + until "$ADB" shell pidof "$TEST_PKG" >/dev/null 2>&1; do + (( $(date +%s) < proc_deadline )) || { + echo "[smoketest] FAIL: $TEST_PKG never started (no process within 15s — bad launch / instant crash)" + "$ADB" logcat -d 2>/dev/null | grep -E "AndroidRuntime|$TEST_PKG|libc :" | tail -20 + return 1 + } + sleep 1 + done + echo "[smoketest] watching logcat for sentinel (timeout ${SMOKE_TIMEOUT}s)" local end=$(( $(date +%s) + SMOKE_TIMEOUT )) while (( $(date +%s) < end )); do - if "$ADB" logcat -d | grep -q "$SENTINEL"; then + # One capture per iteration: cheaper than 2-3 logcat calls, and lets us + # distinguish "adb/emulator died" (return 3) from "not seen yet". + local snap + if ! snap=$("$ADB" logcat -d 2>/dev/null); then + echo "[smoketest] FAIL: 'adb logcat' failed mid-run — device disconnected / emulator died" >&2 + return 3 + fi + if grep -q "$SENTINEL" <<<"$snap"; then echo "[smoketest] PASS: sentinel found" - "$ADB" logcat -d | grep "$SENTINEL" | head -1 + grep "$SENTINEL" <<<"$snap" | head -1 return 0 fi - if "$ADB" logcat -d | grep -qE 'AndroidRuntime: FATAL|XR_ERROR_INITIALIZATION_FAILED|XR_ERROR_RUNTIME_UNAVAILABLE'; then + if grep -qE 'AndroidRuntime: FATAL|XR_ERROR_INITIALIZATION_FAILED|XR_ERROR_RUNTIME_UNAVAILABLE' <<<"$snap"; then echo "[smoketest] FAIL: hit a hard error before sentinel" - "$ADB" logcat -d | grep -E 'AndroidRuntime: FATAL|XR_ERROR_|monado\.' | tail -20 + grep -E 'AndroidRuntime: FATAL|XR_ERROR_|monado\.' <<<"$snap" | tail -20 + return 1 + fi + # Process vanished after starting → crashed before reaching the sentinel. + if ! "$ADB" shell pidof "$TEST_PKG" >/dev/null 2>&1; then + echo "[smoketest] FAIL: $TEST_PKG exited before sentinel (crash mid-bring-up)" + grep -E "$TEST_PKG|AndroidRuntime|libc :|DEBUG|XR_ERROR_" <<<"$snap" | tail -20 return 1 fi sleep 2 done echo "[smoketest] FAIL: sentinel not seen within ${SMOKE_TIMEOUT}s" - "$ADB" logcat -d | tail -30 + "$ADB" logcat -d 2>/dev/null | tail -30 return 2 } # ----- main ------------------------------------------------------------------- ensure_device +assert_abi build_plugin_and_install_jnilibs build_apks smoke_test From e3fd4fb5a1e4d41d4358c5d27e569497b5d5baf0 Mon Sep 17 00:00:00 2001 From: leaiss Date: Tue, 2 Jun 2026 10:04:42 -0700 Subject: [PATCH 5/5] android(smoketest): honor documented PLUGIN_BRANCH env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script header advertised PLUGIN_BRANCH ("branch to check out in the plug-in repo") but build_plugin_and_install_jnilibs never acted on it — a silent no-op that built whatever the plug-in tip happened to be. Check it out (requiring a clean plug-in tree, aborting otherwise) so a smoke run pins a known plug-in revision, matching the bring-up checklist's `PLUGIN_BRANCH=docs/cnsdk-c-abi-surface` instruction. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/android-smoketest.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/android-smoketest.sh b/scripts/android-smoketest.sh index 47664c1ad..ff5850889 100644 --- a/scripts/android-smoketest.sh +++ b/scripts/android-smoketest.sh @@ -142,6 +142,20 @@ build_plugin_and_install_jnilibs() { echo "[smoketest] building plug-in + installing transitive jniLibs" ( cd "$PLUGIN_DIR" + # Honor PLUGIN_BRANCH (documented in the header): check it out before + # building so the smoke test builds a known plug-in revision. Requires a + # clean plug-in working tree; abort loudly rather than build a stale tip. + if [[ -n "${PLUGIN_BRANCH:-}" ]]; then + if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + echo "ERROR: PLUGIN_BRANCH set but $PLUGIN_DIR has uncommitted changes" >&2 + exit 3 + fi + echo "[smoketest] checking out plug-in branch: $PLUGIN_BRANCH" + git checkout "$PLUGIN_BRANCH" >/dev/null 2>&1 || { + echo "ERROR: failed to check out plug-in branch '$PLUGIN_BRANCH'" >&2 + exit 3 + } + fi CNSDK_ROOT="$CNSDK_ROOT" \ DXR_RUNTIME_SOURCE_DIR="$RUNTIME_ROOT" \ bash scripts/build-android.sh install-runtime-jnilibs