feat: launch Quest SteamVR and harden XR lifecycle

This commit is contained in:
XxJSONDeruloxX committed 2026-10-14 18:10:20 -04:00
1 parent e1d096127c
commit 7277386cd3
8 files changed
+152 -52

No files matched your search

@@ -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()
}
}
@@ -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
@@ -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 {
+33
View File
@@ -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.
+29 -5
View File
@@ -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<void **>(&source)) >= 0) {
int pollTimeout = running_ ? 0 : 100;
for (int eventCount = 0; eventCount < 32; eventCount++) {
if (ALooper_pollOnce(pollTimeout, nullptr, &events, reinterpret_cast<void **>(&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<QuestXR *>(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};
}
+11 -5
View File
@@ -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()
+37 -17
View File
@@ -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}
+1 -1
View File
@@ -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) {