feat: add Meta Quest immersive launch mode with Xbox-mapped Touch controllers - #1753
feat: add Meta Quest immersive launch mode with Xbox-mapped Touch controllers#1753moi952 wants to merge 7 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
30 issues found across 54 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt">
<violation number="1" location="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt:80">
P2: OBB files copied to `/Android/obb/$packageName` are never removed when installation is cancelled or the Android game is uninstalled. This leaves potentially large expansion files permanently on shared storage after `SteamService.deleteApp()` removes the depot; cleanup should cover the OBB destination on both paths.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt:144">
P2: A missing or unreadable downloaded APK is treated as proof that no system app needs removal, so callers proceed to delete GameNative's install record while a previously installed Android package may remain. Preserving the resolved package name or blocking deletion when package resolution is unavailable would avoid orphaning the app.</violation>
<violation number="3" location="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt:206">
P2: Destroying or recreating the host Activity while the uninstall dialog is open can leave `requestUninstall()` suspended forever because its raw activity-result registration is not lifecycle-bound, while the caller's coroutine is not cancelled with the Activity. A lifecycle-aware launcher or explicit destruction/recreation recovery would ensure the uninstall flow always completes or returns cancellation.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt:118">
P2: Opening the game manager can block the UI while this platform check synchronously loads every container configuration on the main dispatcher. Resolving the Android-platform flag and depot lookup on `Dispatchers.IO` would avoid dialog jank for users with many containers.</violation>
</file>
<file name="app/src/main/cpp/xrimmersive/xr_immersive.h">
<violation number="1" location="app/src/main/cpp/xrimmersive/xr_immersive.h:113">
P2: The Vulkan direct-render path reimports the shared buffer on every frame: `DirectVulkanBridge` calls this API for each present, and the native implementation treats every call as a buffer change even when the pointer is identical. Keeping the current image when the buffer identity is unchanged (and only marking it changed on an actual replacement) would avoid per-frame EGL image creation/destruction and the resulting frame-time regressions.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/enums/AppFilter.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/enums/AppFilter.kt:88">
P2: Selecting Android with no app-type filter active shows an empty library: `getAppType()` does not represent `ANDROID`, while `onFilterApps` requires every Steam item to match the resulting empty `currentFilter`. Treat Android as a non-type filter and make the type predicate pass when no type is selected, or prevent clearing the last app-type filter.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt">
<violation number="1">
P1: The game can freeze on the last Vulkan frame after screen effects switch the renderer out of scanout: `directRenderActive` remains true after scanout teardown, so this branch keeps skipping PixelCopy. Reset the active state when scanout is disabled or make the bridge report scanout loss before selecting this fast path.</violation>
<violation number="2">
P1: GL immersive sessions can show no game frame when HardwareBuffer import or FBO setup fails: attaching the bridge unconditionally disables PixelCopy before direct rendering is confirmed. Keeping PixelCopy active until `directRenderActive` becomes true, or exposing an explicit bridge-ready state, preserves the intended fallback.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt:393">
P2: Android install-state refreshes can block the UI because this override synchronously scans the game directory and parses the APK from the main-thread refresh path. Moving package lookup to an IO-backed state/cache would avoid jank during download, install, and package-broadcast refreshes.</violation>
</file>
<file name="app/src/main/java/app/gamenative/service/SteamService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/service/SteamService.kt:2249">
P2: Android games that require OBB files can be installed without their game data and then fail to start: `copyObbFiles` swallows the placement failure, while this line proceeds with the install prompt. The Android install path should use a storage mechanism that can place OBBs or surface the package as unsupported before prompting.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/service/SteamService.kt:2249">
P2: Android installation or game launch can be silently blocked when a download finishes while the app is backgrounded, because this completion path starts the installer/game from the service's application context. Routing the action through a visible activity or a notification/PendingIntent would preserve the install flow.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/component/QuickMenu.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/component/QuickMenu.kt:1653">
P2: Repeated A activation on a focused choice chip can use stale state because the bypass stores the callback from the focus-change composition only. Updating the registered callback whenever `onClick` changes (preferably via `rememberUpdatedState`) keeps immersive activation aligned with the current UI state.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/ui/component/QuickMenu.kt:1859">
P1: Immersive slider adjustments can become stuck after the first tick: the registered `(onDecrease, onIncrease)` pair captures the values from the recomposition that locked the row, but the effect never refreshes it when those lambdas change. Register the latest callbacks (for example with `rememberUpdatedState`, or by including both callbacks in the effect keys) so each native press uses current state.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt:1416">
P2: A fast Play tap after changing the checkbox can launch through the previous mode because persistence is fire-and-forget, and rapid toggles can leave the setting written out of order. Serializing or awaiting this save before launch would ensure the mode used by `MainViewModel.launchApp()` matches the selected UI state.</violation>
</file>
<file name="app/src/main/cpp/xrimmersive/jni_bridge.cpp">
<violation number="1" location="app/src/main/cpp/xrimmersive/jni_bridge.cpp:114">
P2: Frames with a row stride larger than `width * 4` will be corrupted because `info.stride` is ignored before `submitFrame()` treats the pixels as tightly packed. Copy each row into a contiguous buffer, or pass the stride through the session API and handle it during upload.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt:46">
P1: After the GLSurfaceView recreates its EGL context, this size-only fast path reuses stale texture/FBO names and leaves the immersive GL target invalid. The bridge needs to be reset or re-imported from the renderer's context-creation lifecycle, otherwise immersive output can remain black until the surface size changes.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt:108">
P1: The XR render thread can sample the shared buffer while the GLRenderer thread still has queued writes, producing stale or partially rendered frames. The direct path needs an explicit cross-context synchronization mechanism, such as an EGL fence carried to the consumer, before handing the buffer to OpenXR.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt:1306">
P2: Immersive A activation still cannot reliably toggle the shader options: `ScreenEffectToggleRow` has no focused `reportActivate` callback, so it uses the synthetic DPAD/A fallback that this bypass was added to avoid. The toggle rows would benefit from the same focused activation registration as `ScreenEffectRadioRow`.</violation>
</file>
<file name="app/src/main/AndroidManifest.xml">
<violation number="1" location="app/src/main/AndroidManifest.xml:143">
P2: A Quest density or screen-layout update while immersive mode is active will recreate `ImmersiveXrActivity` because those qualifiers are not handled here, interrupting the activity-owned OpenXR session and game presentation. Including `density|screenLayout` (with any required UI rebind handling) would preserve the immersive session across these configuration changes.</violation>
</file>
<file name="app/src/main/java/com/winlator/renderer/GLRenderer.java">
<violation number="1" location="app/src/main/java/com/winlator/renderer/GLRenderer.java:164">
P2: With a GL screen effect enabled, immersive output bypasses the configured effect chain and the compositor reads an unpopulated scene buffer, producing incorrect or blank post-processed output. The XR bridge needs to wrap the final effect output, or effect rendering needs an XR-aware target path instead of rebinding inside `drawScene()`.</violation>
<violation number="2" location="app/src/main/java/com/winlator/renderer/GLRenderer.java:168">
P1: After leaving immersive mode, the normal GL surface can remain cropped or rendered with the XR target dimensions because the XR size override survives bridge detachment. Clearing the render-target override whenever the bridge is removed would restore the normal surface dimensions and transform.</violation>
</file>
<file name="app/src/main/java/com/winlator/renderer/VulkanRenderer.java">
<violation number="1" location="app/src/main/java/com/winlator/renderer/VulkanRenderer.java:470">
P1: Immersive output can switch to the wrong window whenever another mapped Vulkan window issues Present: this hook forwards every presented window's AHB into the session's single shared-game-buffer slot. Restrict the bridge to the game's primary/full-frame drawable, or pass a source identity, so overlays and child windows cannot replace the game frame.</violation>
<violation number="2" location="app/src/main/java/com/winlator/renderer/VulkanRenderer.java:470">
P1: The XR render thread can sample this AHardwareBuffer while the Vulkan producer is still writing or reusing it, causing tearing or corrupt frames: the new bridge publishes only the raw pointer and does not propagate the Present completion fence. Propagating and waiting on the producer fence, or otherwise synchronizing ownership before publishing the buffer to OpenXR, would make the zero-copy path safe.</violation>
</file>
<file name="app/src/main/java/com/winlator/container/Container.java">
<violation number="1" location="app/src/main/java/com/winlator/container/Container.java:97">
P2: Duplicating a container silently disables its saved immersive launch preference because the new field is not included in `ContainerManager.duplicateContainer`. Preserving this per-container setting during duplication would keep the duplicated game’s launch behavior consistent.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt:20">
P2: This JNI entry point requires an Activity, but its Kotlin signature accepts any Context. Typing the parameter as `Activity` (or validating/casting it before the native call) would prevent callers from passing an application context that cannot satisfy OpenXR's `applicationActivity` requirement.</violation>
</file>
<file name="app/build.gradle.kts">
<violation number="1" location="app/build.gradle.kts:368">
P2: Every legacy, modern, and non-XR APK now resolves and packages the OpenXR AAR even though immersive mode is limited to the Quest XR path, increasing non-immersive artifacts and exposing unnecessary OpenXR metadata. Scoping this to `modernXrImplementation` would preserve the claimed non-immersive footprint.
(Based on your team's feedback about avoiding unnecessary or broken build dependencies.) [b5ac66e0-cccc-4d8b-b90a-a5fce1209c46]</violation>
<violation number="2" location="app/build.gradle.kts:368">
P1: The added AAR introduces a second, different `libopenxr_loader.so` at the same APK path as the checked-in loader, so native-library merging/packaging will fail or select an arbitrary loader. Keeping only one loader source—by removing the checked-in copy when consuming the AAR, or otherwise excluding the AAR's JNI payload while retaining its Prefab metadata—avoids the conflict.</violation>
</file>
<file name="app/src/main/cpp/xrimmersive/xr_immersive.cpp">
<violation number="1" location="app/src/main/cpp/xrimmersive/xr_immersive.cpp:75">
P2: XR pointer-mode double-clicks can be lost when polling does not align with the frame that generated the edge. Accumulating `pointerModeToggled` in the same way as `quickMenuClicked` would ensure each toggle reaches Kotlin once.</violation>
<violation number="2" location="app/src/main/cpp/xrimmersive/xr_immersive.cpp:109">
P1: The local AHardwareBuffer pointer is borrowed after the mutex is released, so a concurrent buffer replacement can release the last reference before EGL imports it and cause use-after-free or a driver crash. Acquiring a separate local reference before unlocking, or retaining ownership until the import completes, would make this cross-thread handoff safe.</violation>
<violation number="3" location="app/src/main/cpp/xrimmersive/xr_immersive.cpp:174">
P1: A swapchain-acquire failure still submits a quad layer referencing an image that was never acquired, which can make `xrEndFrame()` fail and destabilize the XR frame loop. Propagating render/acquire success and submitting a zero-layer frame when rendering cannot proceed would preserve the OpenXR frame lifecycle.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| @@ -0,0 +1,1758 @@ | |||
| package app.gamenative.ui.screen.xr | |||
There was a problem hiding this comment.
P1: GL immersive sessions can show no game frame when HardwareBuffer import or FBO setup fails: attaching the bridge unconditionally disables PixelCopy before direct rendering is confirmed. Keeping PixelCopy active until directRenderActive becomes true, or exposing an explicit bridge-ready state, preserves the intended fallback.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt:
<comment>GL immersive sessions can show no game frame when HardwareBuffer import or FBO setup fails: attaching the bridge unconditionally disables PixelCopy before direct rendering is confirmed. Keeping PixelCopy active until `directRenderActive` becomes true, or exposing an explicit bridge-ready state, preserves the intended fallback.</comment>
|
|
||
| // Official Khronos OpenXR loader (Apache-2.0) for the Meta Quest immersive launch mode's | ||
| // native module (app/src/main/cpp/xrimmersive) — not a Winlator/GameNativeXR dependency. | ||
| implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61") |
There was a problem hiding this comment.
P1: The added AAR introduces a second, different libopenxr_loader.so at the same APK path as the checked-in loader, so native-library merging/packaging will fail or select an arbitrary loader. Keeping only one loader source—by removing the checked-in copy when consuming the AAR, or otherwise excluding the AAR's JNI payload while retaining its Prefab metadata—avoids the conflict.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/build.gradle.kts, line 368:
<comment>The added AAR introduces a second, different `libopenxr_loader.so` at the same APK path as the checked-in loader, so native-library merging/packaging will fail or select an arbitrary loader. Keeping only one loader source—by removing the checked-in copy when consuming the AAR, or otherwise excluding the AAR's JNI payload while retaining its Prefab metadata—avoids the conflict.</comment>
<file context>
@@ -348,6 +363,10 @@ dependencies {
+ // Official Khronos OpenXR loader (Apache-2.0) for the Meta Quest immersive launch mode's
+ // native module (app/src/main/cpp/xrimmersive) — not a Winlator/GameNativeXR dependency.
+ implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61")
+
// Winlator
</file context>
|
|
||
| // Official Khronos OpenXR loader (Apache-2.0) for the Meta Quest immersive launch mode's | ||
| // native module (app/src/main/cpp/xrimmersive) — not a Winlator/GameNativeXR dependency. | ||
| implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61") |
There was a problem hiding this comment.
P2: Every legacy, modern, and non-XR APK now resolves and packages the OpenXR AAR even though immersive mode is limited to the Quest XR path, increasing non-immersive artifacts and exposing unnecessary OpenXR metadata. Scoping this to modernXrImplementation would preserve the claimed non-immersive footprint.
(Based on your team's feedback about avoiding unnecessary or broken build dependencies.) [b5ac66e0-cccc-4d8b-b90a-a5fce1209c46]
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/build.gradle.kts, line 368:
<comment>Every legacy, modern, and non-XR APK now resolves and packages the OpenXR AAR even though immersive mode is limited to the Quest XR path, increasing non-immersive artifacts and exposing unnecessary OpenXR metadata. Scoping this to `modernXrImplementation` would preserve the claimed non-immersive footprint.
(Based on your team's feedback about avoiding unnecessary or broken build dependencies.) [b5ac66e0-cccc-4d8b-b90a-a5fce1209c46]</comment>
<file context>
@@ -348,6 +363,10 @@ dependencies {
+ // Official Khronos OpenXR loader (Apache-2.0) for the Meta Quest immersive launch mode's
+ // native module (app/src/main/cpp/xrimmersive) — not a Winlator/GameNativeXR dependency.
+ implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61")
+
// Winlator
</file context>
|
|
||
| // Get Downloadable Depots | ||
| val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId) | ||
| val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId)) |
There was a problem hiding this comment.
P2: Opening the game manager can block the UI while this platform check synchronously loads every container configuration on the main dispatcher. Resolving the Android-platform flag and depot lookup on Dispatchers.IO would avoid dialog jank for users with many containers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt, line 118:
<comment>Opening the game manager can block the UI while this platform check synchronously loads every container configuration on the main dispatcher. Resolving the Android-platform flag and depot lookup on `Dispatchers.IO` would avoid dialog jank for users with many containers.</comment>
<file context>
@@ -115,7 +115,7 @@ fun GameManagerDialog(
// Get Downloadable Depots
- val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId)
+ val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId))
downloadableDepots.putAll(allPossibleDownloadableDepots)
</file context>
| val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId)) | |
| val allPossibleDownloadableDepots = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { | |
| SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId)) | |
| } |
| InputSnapshot result = snapshot_; | ||
| // Rising-edge flags are consumed on read. | ||
| snapshot_.quickMenuClicked = false; | ||
| snapshot_.pointerModeToggled = false; |
There was a problem hiding this comment.
P2: XR pointer-mode double-clicks can be lost when polling does not align with the frame that generated the edge. Accumulating pointerModeToggled in the same way as quickMenuClicked would ensure each toggle reaches Kotlin once.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/cpp/xrimmersive/xr_immersive.cpp, line 75:
<comment>XR pointer-mode double-clicks can be lost when polling does not align with the frame that generated the edge. Accumulating `pointerModeToggled` in the same way as `quickMenuClicked` would ensure each toggle reaches Kotlin once.</comment>
<file context>
@@ -0,0 +1,1218 @@
+ InputSnapshot result = snapshot_;
+ // Rising-edge flags are consumed on read.
+ snapshot_.quickMenuClicked = false;
+ snapshot_.pointerModeToggled = false;
+ return result;
+}
</file context>
| void *pixels = nullptr; | ||
| if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS) return; | ||
|
|
||
| handle->session->submitFrame(static_cast<const uint8_t *>(pixels), static_cast<int32_t>(info.width), |
There was a problem hiding this comment.
P2: Frames with a row stride larger than width * 4 will be corrupted because info.stride is ignored before submitFrame() treats the pixels as tightly packed. Copy each row into a contiguous buffer, or pass the stride through the session API and handle it during upload.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/cpp/xrimmersive/jni_bridge.cpp, line 114:
<comment>Frames with a row stride larger than `width * 4` will be corrupted because `info.stride` is ignored before `submitFrame()` treats the pixels as tightly packed. Copy each row into a contiguous buffer, or pass the stride through the session API and handle it during upload.</comment>
<file context>
@@ -0,0 +1,222 @@
+ void *pixels = nullptr;
+ if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS) return;
+
+ handle->session->submitFrame(static_cast<const uint8_t *>(pixels), static_cast<int32_t>(info.width),
+ static_cast<int32_t>(info.height));
+
</file context>
Steam now lets some games be installed as a native Android app instead of the usual Windows build run through Wine (ahead of Valve's Steam Frame headset). This adds a per-game Version choice in Edit Container: picking Android downloads, installs, and launches the game as a real Android app, bypassing Wine entirely for that game.
…cases, staleness bugs, uninstall freeze)
…lt, fix receiver race, clean up staged APKs
… on the main thread
…, add Android-mode depot language test coverage
Description
Note that there are 7 commits listed, but only the last one relates to this feature; I based my code on that pull request. : #1742
So, we need to wait for that one to be approved before reviewing this PR.
Build for test : https://www.transfernow.net/dl/20260723m453mObn
Summary
Implements the Meta Quest immersive/VR launch mode end-to-end: a native OpenXR session that renders the game directly into the headset via a zero-copy GPU buffer (GL and Vulkan paths), Touch controllers mapped as a standard Xbox controller for Wine, a full port of the quick menu's input handling to work with that controller mapping, and the launch entry point (per-game checkbox, manifest activity, container flag) that ties it all together. There was no immersive mode before this commit — everything below is new.
Native OpenXR session and direct GPU-to-headset rendering
xrimmersivenative module (app/src/main/cpp/xrimmersive): opens an OpenXR session against the official Khronos loader (org.khronos.openxr:openxr_loader_for_android, Apache-2.0 — not a Winlator/GameNativeXR dependency), submits the game's frame as a composition-layer quad positioned/oriented in front of the user, and supports passthrough (requires declaringcom.oculus.feature.PASSTHROUGHin the manifest, otherwise Horizon OS silently omits theXR_FB_passthroughextension).DirectGLBridge(legacy GL renderer): allocates aHardwareBuffer, imports it as a GL texture/FBO insideGLRenderer's own EGL context (native import viaGLHardwareBufferImporter, since the public SDK has no usable Java binding for that), and hands the same buffer to the OpenXR session to import a second time and sample directly.DirectVulkanBridge(default dxvk/Vulkan renderer):VulkanRendereralready produces anAHardwareBufferon every real frame (onUpdateWindowContentDirect/nativeUpdateWindowContentAHB) — this just forwards that same pointer straight to the OpenXR session.XrFrameBridge/VulkanXrFrameBridgeinterface pair,nullon every non-immersive launch, so the existing 2D panel rendering path is unaffected either way.Touch controllers mapped as an Xbox controller
XrGamepadBridge: translates native OpenXR controller state intoWinHandler's existing shared-memory gamepad buffer (the same one real Bluetooth/USB controllers use), so from the Windows game's point of view a Touch controller is indistinguishable from a physical Xbox controller. No synthetic d-pad — Touch controllers don't have one, and faking it from the thumbstick fought with the real analog values.Launch entry point
MainActivity.isMetaQuest) and only for an installed game; persisted as a newContainer.launchImmersiveModeflag.MainViewModel.launchAppbranches toImmersiveXrActivity.start(...)instead of the normalMainUiEvent.LaunchAppwhen that flag is set on a Quest device; immersive launches always boot straight to the executable (no "open the Wine desktop" step).ImmersiveXrActivity(com.oculus.intent.category.VR, noLAUNCHERcategory — reachable only via the checkbox). Deliberately keeps the defaulttaskAffinity(same task asMainActivity, no separate process): an earlier attempt atexcludeFromRecentsforced Android to spin up a genuinely separate task, whose teardown firedSteamService.onTaskRemoved()independently ofMainActivity's own lifecycle checks and force-stopped the Steam connection mid-session (looked like a permanent "stuck on Launching" hang, fixed alongside by makingonTaskRemovedrespect the samekeepAliveflagMainActivityalready does).Quick menu ported to immersive input
The quick menu's existing Compose UI is reused as-is; everything below is about making its focus/click/lock model work reliably when driven by native OpenXR controller polling instead of real Android
KeyEvents.LocalImmersiveInputBypassCompositionLocal so any row — current or future — can report "I'm focused and want BUTTON_A to activate/adjust me" without threading a dedicated callback through every intermediate composable. Used it for:QuickMenuAdjustmentRow, andScreenEffectsPanel'sScreenEffectAdjustmentRow).ScreenEffectRadioRow(upscaler mode, colorimetry),QuickMenuChoiceChip(LSFG multiplier), andQuickMenuTabButton(the tab rail — previously only reachable via LB/RB, not joystick+A).ImmersiveControls), for adjusting the quad's distance/scale and passthrough while in a session.toggleQuickMenu()(open/close only) rather than the sharedgameBack()handler, which carries unrelated IME/controller-rescan/touchpad side effects.KeyEvents through a path entirely separate from this module's native OpenXR polling (confirmed via logs), and Android's own key-fallback mechanism turns an unconsumedBUTTON_STARTintoDPAD_CENTER. Fixed by pushing the button's physical-hold state into the quick menu, which now consumes any key event reaching it while visible and the button is held.BUTTON_Alocks (idempotent — a second press while already locked is a no-op, it does not toggle back off);BUTTON_Bunlocks. FixedBUTTON_Bnot actually matching its own check — this Activity dispatchesKEYCODE_BACKfor B, but the row only checkedKEYCODE_BUTTON_B, so unlocking previously only ever happened as a side effect of the unconsumed BACK event clearing Compose focus.Known remaining issue: sliders (e.g. FPS limiter target) don't reliably step through every value when held/repeated in some cases — root cause not yet confirmed, deferred for a follow-up with fresh diagnostics.
Fixed a stuck "Launching game..." splash after exiting an immersive session
ImmersiveXrActivityhosts its own separateMainViewModelinstance fromMainActivity's. Both stay subscribed to the same globalSetBootingSplashTextevent bus for the whole session, soMainActivity's (backgrounded) instance kept getting its splash re-armed by every boot-sequence text update and was never told the session ended — only the immersive Activity's own instance got reset on exit. Added aClearBootingSplashglobal event, broadcast from every existing reset call site (exitSteamApp,onWindowMapped,onGameLaunchError), so any subscribedMainViewModelinstance clears itself regardless of which Activity triggered the reset.Recording
Type of Change
Checklist
#code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.CONTRIBUTING.md.Summary by cubic
Adds a Meta Quest immersive VR launch mode with native OpenXR and zero‑copy rendering, plus native Android build install/launch so supported games run as real Android apps. Also adds Android platform selection and filtering, and updates depot selection and size display to respect Android builds.
New Features
org.khronos.openxr:openxr_loader_for_android, zero‑copy GL/Vulkan bridges usingAHardwareBuffer, Touch→Xbox mapping, passthrough, Immersive quick‑menu tab, andImmersiveXrActivity. Ships prebuiltlibxrimmersive.so.AndroidGameLauncher, Android‑only library filter, and Android‑aware depot filtering, language resolution, and size display.Bug Fixes
ClearBootingSplashevent.Written for commit cd2e4bb. Summary will update on new commits.