mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat: launch Quest SteamVR and harden XR lifecycle
This commit is contained in:
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user