From 7277386cd36a04ede26cc83049485681b45ce88a Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Wed, 14 Oct 2026 18:10:20 -0400 Subject: [PATCH] feat: launch Quest SteamVR and harden XR lifecycle --- .../com/droiddeck/launcher/QuestXrActivity.kt | 59 +++++++++++-------- .../com/droiddeck/launcher/SessionActivity.kt | 5 ++ .../launcher/wayland/CompositorHost.kt | 1 + docs/development/quest-openxr.md | 33 +++++++++++ questxr/src/main/cpp/quest_xr.cpp | 34 +++++++++-- tools/deploy_quest_xr.ps1 | 16 +++-- tools/test_quest_xr.py | 54 +++++++++++------ tools/vr/src/driver_droiddeck.cpp | 2 +- 8 files changed, 152 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt index 6e7b4e2e..46f72235 100644 --- a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt @@ -3,51 +3,62 @@ package com.droiddeck.launcher import android.app.NativeActivity import android.os.Bundle import android.view.Surface +import android.util.Log +import com.droiddeck.launcher.gpu.SteamVr import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.wayland.CompositorHost import com.droiddeck.launcher.wayland.WaylandCompositor -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit class QuestXrActivity : NativeActivity() { private var presentedBaseline = 0L + private var steamVrStarted = false + @Volatile private var closing = false + @Volatile private var steamVrStatus = SteamVr.Status.UNAVAILABLE override fun onCreate(savedInstanceState: Bundle?) { if (intent.getBooleanExtra("guest_surface", false)) { require(CompositorHost.isStarted) { "Start a DroidDeck session before attaching XR" } intent.putExtra("surface_test", true) + SessionService.resume(this) } super.onCreate(savedInstanceState) } fun onXrSurface(surface: Surface?, width: Int, height: Int) { - val complete = CountDownLatch(1) - var failure: Throwable? = null - runOnUiThread { - try { - if (surface != null) { - presentedBaseline = WaylandCompositor.nativePresentedFrames() - WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f) - WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) - WaylandCompositor.nativeSetZeroCopy(false) - WaylandCompositor.nativeSetOutputSize(width, height) - WaylandCompositor.nativeSetFpsLimit(0) - } - CompositorHost.attachXr(surface) - SessionService.setActivityVisible(this, surface != null) - WaylandCompositor.nativeVsync(System.nanoTime()) - } catch (error: Throwable) { - failure = error - } finally { - complete.countDown() - } + if (surface != null) { + presentedBaseline = WaylandCompositor.nativePresentedFrames() + WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) + WaylandCompositor.nativeSetZeroCopy(false) + WaylandCompositor.nativeSetOutputSize(width, height) + WaylandCompositor.nativeSetFpsLimit(0) } - check(complete.await(3, TimeUnit.SECONDS)) { "XR surface UI handshake timed out" } - failure?.let { throw it } + CompositorHost.attachXr(surface) + SessionService.setActivityVisible(this, surface != null) + WaylandCompositor.nativeVsync(System.nanoTime()) check(WaylandCompositor.nativeWaitSurface(3000)) { "XR surface GPU handshake timed out" } + if (surface != null && intent.getBooleanExtra("start_steamvr", false) && !steamVrStarted) { + steamVrStarted = true + Thread({ + if (!SteamVr.run(this, "start")) Log.e("DroidDeckXR", "SteamVR start failed") + while (!closing) { + steamVrStatus = SteamVr.status(this) + Thread.sleep(1000) + } + SteamVr.run(this, "stop") + }, "quest-steamvr-start").start() + } } fun onXrFrame(frameTimeNanos: Long): Long { WaylandCompositor.nativeVsync(frameTimeNanos) return (WaylandCompositor.nativePresentedFrames() - presentedBaseline).coerceAtLeast(0) } + + fun onXrStatus(): Int = steamVrStatus.ordinal + + override fun onDestroy() { + closing = true + if (steamVrStarted) Thread({ SteamVr.run(this, "stop") }, "quest-steamvr-stop").start() + super.onDestroy() + } } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 0c921c80..65ac7242 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -2325,6 +2325,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun steamVrAction(action: String) { + if (action == "start" && com.droiddeck.launcher.session.QuestExperimental.isSupported()) { + startActivity(android.content.Intent(this, QuestXrActivity::class.java) + .putExtra("guest_surface", true).putExtra("start_steamvr", true)) + return + } if (action == "start") { switchPictureForSteamVr() steamVrSeenRunning = false diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index a68f804a..8202524a 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -7,6 +7,7 @@ import com.droiddeck.launcher.session.SessionState object CompositorHost { @Volatile private var started = false + @Volatile private var vsyncRunning = false private val vsync = object : Choreographer.FrameCallback { diff --git a/docs/development/quest-openxr.md b/docs/development/quest-openxr.md index 406e8231..b848d304 100644 --- a/docs/development/quest-openxr.md +++ b/docs/development/quest-openxr.md @@ -99,6 +99,39 @@ not block native rendering or independent engineering. No guard was bypassed. ## Remaining stages +## Milestone 4: full build and deployment + +Full CI run 38641552327 passed at `e1d09612`, including APK compilation, tracking +IPC tests and ELF closure verification. Its 62,211,156-byte APK was downloaded, +aligned and signed locally with the provided key. The installed original APK's +certificate matched the expected release certificate. Updating the standard +`com.droiddeck.launcher` package with `adb install -r` succeeded; a copy of the +previous APK is retained locally under ignored `build/installed-original.apk`. +No uninstall or data reset was performed. + +The latest source adds the Quest-specific SteamVR Start flow, ongoing SteamVR +status telemetry, a clean native Back/exit path, and bounded Android event +draining. It removes UI-thread waits from native surface handoff to avoid a +NativeActivity destruction deadlock, and restores proximity after tests unless +`--keep-awake` is specified. These changes compile locally but require a fresh +integrated APK before their device validation. + +Later device runs stopped rendering because the OS displayed **Finding position +in room**, saying it cannot detect headset movement. Logs show the XR session +going through STOPPING to IDLE; a sample had only flags 7, lacking tracked +position. State changes are now written immediately to health telemetry, and +tests require all four valid/tracked flags to establish 6DoF. Earlier successful +72 Hz runs remain evidence for those earlier device conditions; the latest +tests are not counted as passes. Physical headset positioning/lighting needs +attention before sustained 6DoF and game validation can resume. + +The actual shared tracking file was read through the debuggable companion: +296 bytes, correct magic/version and even sequence, converted prediction about +21 ms beyond the monotonic sample time. This confirms the IPC time-domain +conversion on the headset. + +## Remaining stages + - Android/Linux eye-image bridge and frame ownership/synchronization. - OpenVR driver IPC for predicted 6DoF head, per-eye views and tracked hands. - Real Touch input mapping and haptics returning through the bridge. diff --git a/questxr/src/main/cpp/quest_xr.cpp b/questxr/src/main/cpp/quest_xr.cpp index 8a90f237..0d4dd9b6 100644 --- a/questxr/src/main/cpp/quest_xr.cpp +++ b/questxr/src/main/cpp/quest_xr.cpp @@ -95,17 +95,25 @@ public: while (!app_->destroyRequested && !exit_) { android_poll_source *source = nullptr; int events = 0; - while (ALooper_pollOnce(running_ ? 0 : 100, nullptr, &events, - reinterpret_cast(&source)) >= 0) { + int pollTimeout = running_ ? 0 : 100; + for (int eventCount = 0; eventCount < 32; eventCount++) { + if (ALooper_pollOnce(pollTimeout, nullptr, &events, reinterpret_cast(&source)) < 0) break; if (source) source->process(app_, source); if (app_->destroyRequested) break; + pollTimeout = 0; } pollEvents(); if (running_ && !app_->destroyRequested) frame(); } + if (exit_ && !app_->destroyRequested) ANativeActivity_finish(app_->activity); } void health(const char *error = "") { + int steamvrState = -1; + if (guestSurface_ && statusCallback_) { + steamvrState = env_->CallIntMethod(app_->activity->clazz, statusCallback_); + if (env_->ExceptionCheck()) { env_->ExceptionClear(); steamvrState = -1; } + } std::string path = std::string(app_->activity->externalDataPath) + "/xr-health.json"; std::string temp = path + ".tmp"; FILE *file = std::fopen(temp.c_str(), "w"); @@ -113,12 +121,12 @@ public: std::fprintf(file, "{\"frames\":%llu,\"rendered\":%llu,\"sessionState\":%d,\"headFlags\":%llu," "\"headPosition\":[%.6f,%.6f,%.6f],\"controllersActive\":[%s,%s]," - "\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"error\":\"%s\"}\n", + "\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"steamvrState\":%d,\"error\":\"%s\"}\n", (unsigned long long)frames_, (unsigned long long)rendered_, state_, (unsigned long long)head_.locationFlags, head_.pose.position.x, head_.pose.position.y, head_.pose.position.z, handActive_[0] ? "true" : "false", handActive_[1] ? "true" : "false", - (long long)period_, surfaceSupported_ ? "true" : "false", guestSurface_ ? "guest" : surfaceTest_ ? "surface" : "native", (long long)guestFrames_, error); + (long long)period_, surfaceSupported_ ? "true" : "false", guestSurface_ ? "guest" : surfaceTest_ ? "surface" : "native", (long long)guestFrames_, steamvrState, error); std::fclose(file); std::rename(temp.c_str(), path.c_str()); } @@ -142,10 +150,11 @@ private: XrSpaceLocation head_{XR_TYPE_SPACE_LOCATION}; XrSpaceLocation handLocations_[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}}; bool handActive_[2]{}, pressed_[2]{}; - bool running_ = false, exit_ = false, surfaceSupported_ = false; + bool running_ = false, exit_ = false, surfaceSupported_ = false, quitRequested_ = false; bool surfaceTest_ = false; bool guestSurface_ = false, guestAttached_ = false; jmethodID surfaceCallback_ = nullptr, frameCallback_ = nullptr; + jmethodID statusCallback_ = nullptr; jobject androidJavaSurface_ = nullptr; DroidDeckXrTracking *tracking_ = nullptr; DroidDeckXrHaptics *haptics_ = nullptr; @@ -171,6 +180,18 @@ private: } void initialize() { + app_->userData = this; + app_->onInputEvent = [](android_app *app, AInputEvent *event) -> int32_t { + if (AInputEvent_getType(event) != AINPUT_EVENT_TYPE_KEY || AKeyEvent_getKeyCode(event) != AKEYCODE_BACK + || AKeyEvent_getAction(event) != AKEY_EVENT_ACTION_UP) return 0; + auto *runtime = static_cast(app->userData); + if (runtime->running_) { + XrResult result = xrRequestExitSession(runtime->session_); + if (XR_SUCCEEDED(result)) runtime->quitRequested_ = true; + else __android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "exit request failed: %d", result); + } else ANativeActivity_finish(app->activity); + return 1; + }; app_->activity->vm->AttachCurrentThread(&env_, nullptr); jclass activityClass = env_->GetObjectClass(app_->activity->clazz); jmethodID getIntent = env_->GetMethodID(activityClass, "getIntent", "()Landroid/content/Intent;"); @@ -186,6 +207,7 @@ private: surfaceTest_ = true; surfaceCallback_ = env_->GetMethodID(activityClass, "onXrSurface", "(Landroid/view/Surface;II)V"); frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)J"); + statusCallback_ = env_->GetMethodID(activityClass, "onXrStatus", "()I"); if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); } } env_->DeleteLocalRef(extra); env_->DeleteLocalRef(intentClass); @@ -560,7 +582,9 @@ private: check(xrBeginSession(session_, &begin), "xrBeginSession"); running_ = true; } else if (state_ == XR_SESSION_STATE_STOPPING) { check(xrEndSession(session_), "xrEndSession"); running_ = false; + if (quitRequested_) exit_ = true; } else if (state_ == XR_SESSION_STATE_EXITING || state_ == XR_SESSION_STATE_LOSS_PENDING) exit_ = true; + health(); } else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) exit_ = true; event = {XR_TYPE_EVENT_DATA_BUFFER}; } diff --git a/tools/deploy_quest_xr.ps1 b/tools/deploy_quest_xr.ps1 index a69512c4..505a91ce 100644 --- a/tools/deploy_quest_xr.ps1 +++ b/tools/deploy_quest_xr.ps1 @@ -3,7 +3,8 @@ param( [Parameter(Mandatory = $true)][string]$Adb, [string]$Sdk = "$env:USERPROFILE\android-sdk", [string]$NdkVersion = '27.3.13750724', - [string]$BuildTools = '35.0.0' + [string]$BuildTools = '35.0.0', + [string]$Apk ) $ErrorActionPreference = 'Stop' function Invoke-Checked([string]$Program, [string[]]$Arguments) { @@ -14,11 +15,16 @@ function Invoke-Checked([string]$Program, [string[]]$Arguments) { $repo = Split-Path $PSScriptRoot -Parent Push-Location $repo try { - Invoke-Checked .\gradlew.bat @(':questxr:assembleDebug', "-PndkVersion=$NdkVersion", '-PskipRust=true', '--console=plain') + if (-not $Apk) { + Invoke-Checked .\gradlew.bat @(':questxr:assembleDebug', "-PndkVersion=$NdkVersion", '-PskipRust=true', '--console=plain') + $Apk = 'questxr\build\outputs\apk\debug\questxr-debug.apk' + } + if (-not (Test-Path -LiteralPath $Apk -PathType Leaf)) { throw 'APK input does not exist' } + New-Item -ItemType Directory -Path build\quest-deploy -Force | Out-Null $tools = Join-Path $Sdk "build-tools\$BuildTools" - $aligned = 'questxr\build\questxr-aligned.apk' - $signed = 'questxr\build\questxr-signed.apk' - Invoke-Checked "$tools\zipalign.exe" @('-f', '-p', '4', 'questxr\build\outputs\apk\debug\questxr-debug.apk', $aligned) + $aligned = 'build\quest-deploy\aligned.apk' + $signed = 'build\quest-deploy\signed.apk' + Invoke-Checked "$tools\zipalign.exe" @('-f', '-p', '4', $Apk, $aligned) Invoke-Checked "$tools\apksigner.bat" @('sign', '--key', "$KeyDirectory\droiddeck-release.pk8", '--cert', "$KeyDirectory\droiddeck-release.x509.pem", '--out', $signed, $aligned) $certificate = Invoke-Checked "$tools\apksigner.bat" @('verify', '--print-certs', $signed) $expected = (Get-Content keystore\release-signer.sha256 -Raw).Trim() diff --git a/tools/test_quest_xr.py b/tools/test_quest_xr.py index a53afb66..de71d6a8 100644 --- a/tools/test_quest_xr.py +++ b/tools/test_quest_xr.py @@ -17,15 +17,19 @@ def main(): parser.add_argument('--require-controllers', action='store_true') parser.add_argument('--surface', action='store_true') parser.add_argument('--guest', action='store_true') + parser.add_argument('--keep-awake', action='store_true') + parser.add_argument('--steamvr', action='store_true') args = parser.parse_args() if args.seconds < 5 or args.seconds > 600: parser.error('--seconds must be between 5 and 600') + if args.steamvr and not args.guest: + parser.error('--steamvr requires --guest') serial = resolve_serial(args.adb, args.serial) args.output.mkdir(parents=True, exist_ok=True) def adb(*command): return subprocess.run([args.adb, '-s', serial, *command], check=True, - capture_output=True, text=True).stdout + capture_output=True, text=True, timeout=15).stdout package = 'com.droiddeck.launcher' if args.guest else 'com.droiddeck.questxr' health_path = f'/sdcard/Android/data/{package}/files/xr-health.json' @@ -33,40 +37,56 @@ def main(): adb('shell', 'am', 'force-stop', package) adb('shell', 'rm', '-f', health_path) adb('shell', 'input', 'keyevent', 'KEYCODE_WAKEUP') - adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close') component = f'{package}/.QuestXrActivity' if args.guest else f'{package}/android.app.NativeActivity' launch = ['shell', 'am', 'start', '-n', component] if args.surface: launch += ['--ez', 'surface_test', 'true'] if args.guest: launch += ['--ez', 'guest_surface', 'true'] - adb(*launch) + if args.steamvr: + launch += ['--ez', 'start_steamvr', 'true'] samples = [] failure = None - deadline = time.monotonic() + args.seconds - while time.monotonic() < deadline: - time.sleep(1) + try: adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close') - try: - sample = json.loads(adb('shell', 'cat', health_path)) - samples.append(sample) - if sample.get('error'): - failure = sample['error'] - break - except (subprocess.CalledProcessError, json.JSONDecodeError): - pass - log = adb('logcat', '-d', '-s', 'DroidDeckXR:I', 'OpenXR-Loader:W', 'AndroidRuntime:E') + adb(*launch) + deadline = time.monotonic() + args.seconds + while time.monotonic() < deadline: + time.sleep(1) + adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close') + try: + sample = json.loads(adb('shell', 'cat', health_path)) + samples.append(sample) + if sample.get('error'): + failure = sample['error'] + break + except (subprocess.CalledProcessError, json.JSONDecodeError): + pass + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + failure = f'ADB operation failed: {type(error).__name__}' + finally: + if not args.keep_awake: + try: + adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_far') + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + failure = failure or 'Could not restore headset proximity after testing' + try: + log = adb('logcat', '-d', '-s', 'DroidDeckXR:I', 'OpenXR-Loader:W', 'AndroidRuntime:E') + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + log = str(error) (args.output / 'logcat.txt').write_text(log, encoding='utf-8') if len(samples) < 2: failure = failure or 'No advancing telemetry; check runtime initialization and headset wake/proximity' elif samples[-1]['rendered'] - samples[0]['rendered'] < 72: failure = failure or 'Fewer than 72 additional stereo frames were submitted' - elif not any(sample['sessionState'] == 5 and sample['headFlags'] & 3 == 3 for sample in samples): - failure = failure or 'No focused session with valid position and orientation' + elif not any(sample['sessionState'] == 5 and sample['headFlags'] & 15 == 15 for sample in samples): + failure = failure or 'No focused session with tracked 6DoF position and orientation' elif args.require_controllers and not any(all(sample['controllersActive']) for sample in samples): failure = failure or 'Both controllers did not become active' elif args.guest and samples[-1].get('guestFrames', 0) - samples[0].get('guestFrames', 0) < 72: failure = failure or 'No advancing guest GPU presentation; submitting an XR layer alone is insufficient' + elif args.steamvr and samples[-1].get('steamvrState') != 4: + failure = failure or 'SteamVR did not report RUNNING' elif samples[-1].get('source') != ('guest' if args.guest else 'surface' if args.surface else 'native'): failure = failure or 'Wrong projection source' report = {'ok': failure is None, 'error': failure, 'samples': samples} diff --git a/tools/vr/src/driver_droiddeck.cpp b/tools/vr/src/driver_droiddeck.cpp index 2239d836..1f4d96ef 100644 --- a/tools/vr/src/driver_droiddeck.cpp +++ b/tools/vr/src/driver_droiddeck.cpp @@ -424,7 +424,7 @@ public: VRDriverInput()->UpdateBooleanComponent(padTouch_, hand.active && (std::abs(hand.stick[0]) + std::abs(hand.stick[1]) > 0.01f || (hand.buttons & 1u)), 0); VRDriverInput()->UpdateBooleanComponent(menu_, hand.active && (hand.buttons & 12u), 0); - VRDriverInput()->UpdateBooleanComponent(system_, false, 0); + VRDriverInput()->UpdateBooleanComponent(system_, hand.active && !right_ && (hand.buttons & 8u), 0); } void Update(const HmdQuaternion_t &head, const PadState *pad) {