Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ android {
buildFeatures {
compose = true
buildConfig = true
// Exposes the openxr_loader_for_android AAR's native headers/lib to CMake, for the
// (not yet wired into the default build — see xrimmersive/CMakeLists.txt) immersive
// VR native module.
prefab = true
}

packaging {
Expand Down Expand Up @@ -314,6 +318,17 @@ android {
// }
// }

// Meta Quest immersive launch mode's native OpenXR module. Same convention as the
// other native modules above: not part of the default build (native libs ship as
// prebuilt .so files in jniLibs/) — temporarily uncomment to build+test locally,
// then copy the resulting libxrimmersive.so into jniLibs/arm64-v8a/ and re-comment.
// externalNativeBuild {
// cmake {
// path = file("src/main/cpp/xrimmersive/CMakeLists.txt")
// version = "3.22.1"
// }
// }

// (For now) Uncomment for LeakCanary to work.
// configurations {
// debugImplementation {
Expand Down Expand Up @@ -348,6 +363,10 @@ dependencies {
// Split Modules
implementation(libs.bundles.google)

// 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>


// Winlator
implementation(libs.bundles.winlator)
implementation(libs.libarchive.android)
Expand Down
62 changes: 62 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@
android:glEsVersion="0x00020000"
android:required="true" />

<!-- Optional: only used by the Meta Quest immersive launch mode, not required to install/run the app. -->
<uses-feature
android:name="android.hardware.vr.headtracking"
android:version="1"
android:required="false" />

<!-- Required for the Horizon OS OpenXR runtime to advertise XR_FB_passthrough at all — without
this it silently omits the extension (confirmed via logcat: "skipping extension=XR_FB_passthrough
due to: missing uses-feature string com.oculus.feature.PASSTHROUGH"), so the immersive
passthrough toggle stays a no-op even though the code path is otherwise correct. -->
<uses-feature
android:name="com.oculus.feature.PASSTHROUGH"
android:required="false" />

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
Expand All @@ -16,6 +30,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_LOGS" />
<uses-permission android:name="android.permission.VIBRATE" />
Expand Down Expand Up @@ -89,6 +104,53 @@

</activity>

<!--
Meta Quest immersive/VR launch entry point — always boots straight to the game.
com.oculus.intent.category.VR is what tells Horizon OS this Activity is an
immersive VR experience it should hand the compositor to (purple-dots
transition, hiding the 2D panel), not just another windowed task — without it
the OS treats it as an ordinary flat panel even though the OpenXR session
underneath is running fine.

Deliberately NOT split into its own process (android:process=":vr", which the
official Meta samples usually add alongside this category): this app shares a
lot of process-wide state (PluviaApp singletons, SteamService, Room DB access)
between the normal flow and this Activity, and splitting processes would break
all of that — needs a real cross-process redesign if the category alone turns
out not to be enough on its own.

No android.intent.category.LAUNCHER here on purpose: this must stay reachable
only via the in-app checkbox, not as its own icon in the Quest library.

No taskAffinity override (defaults to the app's package name, same as
MainActivity) and no excludeFromRecents: this needs to land in the SAME task as
MainActivity, not a separate one. A previous version set excludeFromRecents="true"
for a cosmetic reason (avoid a second entry in the Quest dashboard/app-switcher),
but that forced Android to create a genuinely separate task for it regardless of
matching taskAffinity — and tearing down THAT task on exit fires SteamService's
onTaskRemoved() independently of MainActivity's own onStop/onDestroy checks,
force-stopping the Steam connection out from under keepAlive=true and causing a
slow full reconnect (looks like "stuck on Launching") every time. Confirmed by
checking how GameNativeXR (WinlatorXR) handles this: their VR activity shares its
default taskAffinity with their main activity for the same reason.
-->
<activity
android:name=".ui.screen.xr.ImmersiveXrActivity"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="false"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|navigation|uiMode|colorMode"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/AndroidManifest.xml, line 143:

<comment>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.</comment>

<file context>
@@ -89,6 +104,53 @@
+            android:launchMode="singleTask"
+            android:resizeableActivity="false"
+            android:screenOrientation="landscape"
+            android:configChanges="orientation|screenSize|keyboard|keyboardHidden|navigation|uiMode|colorMode"
+            android:theme="@style/Theme.Pluvia">
+            <intent-filter>
</file context>
Suggested change
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|navigation|uiMode|colorMode"
android:configChanges="density|orientation|screenLayout|screenSize|keyboard|keyboardHidden|navigation|uiMode|colorMode"

android:theme="@style/Theme.Pluvia">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="com.oculus.intent.category.VR" />
</intent-filter>
<meta-data
android:name="com.oculus.vrshell.supports_free_resizing"
android:value="true" />
</activity>

<!-- GOG OAuth (in-app WebView; intercepts redirect for automatic code capture) -->
<activity
android:name=".ui.screen.auth.GOGOAuthActivity"
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/cpp/xrimmersive/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Native OpenXR session for the Meta Quest "immersive launch" mode.
#
# Built against the official Khronos OpenXR loader (org.khronos.openxr:openxr_loader_for_android,
# Apache-2.0, pulled in as a Gradle/prefab dependency in app/build.gradle.kts) — no code or
# binaries from any third-party Winlator VR fork are used here.
#
# NOT wired into the normal Gradle build (see the other commented-out externalNativeBuild
# blocks in app/build.gradle.kts) — this project ships native libs as prebuilt .so files
# committed to jniLibs/, rebuilt manually when needed. To build this module locally:
# 1. Temporarily uncomment the externalNativeBuild { cmake { path = ... } } block for
# this CMakeLists.txt in app/build.gradle.kts.
# 2. Build, then copy the resulting libxrimmersive.so into
# app/src/main/jniLibs/arm64-v8a/ and re-comment the block.
cmake_minimum_required(VERSION 3.22.1)
project(xrimmersive)

add_library(xrimmersive SHARED
xr_immersive.cpp
jni_bridge.cpp
)

find_package(OpenXR REQUIRED CONFIG)

target_link_libraries(xrimmersive
OpenXR::openxr_loader
EGL
GLESv3
android
jnigraphics
log
)

target_compile_options(xrimmersive PRIVATE -Wall -Wextra -Werror=return-type -Wno-missing-field-initializers)
222 changes: 222 additions & 0 deletions app/src/main/cpp/xrimmersive/jni_bridge.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
#include <jni.h>
#include <android/bitmap.h>
#include <android/hardware_buffer_jni.h>
#include <android/log.h>

#include "xr_immersive.h"

namespace {

struct NativeHandle {
xrimmersive::XrImmersiveSession *session;
jobject activityGlobalRef;
};

#define LOG_TAG "xrimmersive_jni"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

} // namespace

extern "C" {

JNIEXPORT jlong JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeCreate(JNIEnv *env, jclass, jobject activity) {
JavaVM *vm = nullptr;
env->GetJavaVM(&vm);

auto *handle = new NativeHandle();
handle->activityGlobalRef = env->NewGlobalRef(activity);
handle->session = new xrimmersive::XrImmersiveSession();
handle->session->initialize(vm, handle->activityGlobalRef);
return reinterpret_cast<jlong>(handle);
}

JNIEXPORT void JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeRequestStop(JNIEnv *, jclass, jlong handlePtr) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle != nullptr) handle->session->requestStop();
}

JNIEXPORT void JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeJoinAndDestroy(JNIEnv *env, jclass, jlong handlePtr) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle == nullptr) return;
handle->session->join();
delete handle->session;
env->DeleteGlobalRef(handle->activityGlobalRef);
delete handle;
}

// outButtons[0] receives the Xbox-layout button bitmask (see XrGamepadBridge's BUTTON_*);
// outAxes receives [leftX, leftY, rightX, rightY, triggerL, triggerR]. outHandPoses receives
// [leftX,leftY,leftZ, leftFwdX,leftFwdY,leftFwdZ, rightX,rightY,rightZ, rightFwdX,rightFwdY,
// rightFwdZ] (aim-pose ray per hand, in the quad's local space) — only meaningful when
// outFlags[0] (handPosesValid) is true. outFlags[1] is whether the XR-pointer-mode toggle
// (double-click of either thumbstick) fired since the last poll. outFlags[2] is the level state
// of the Menu/Start button — true for its entire physical hold, not just the rising edge — so
// Kotlin can suppress menu-navigation reads while the same hand may still be resting on/near the
// thumbstick. Returns whether the quick-menu trigger (Menu/Start held 600ms) fired since the last
// poll — a short press of the same button is still forwarded as a normal gamepad Start press,
// see xr_immersive.cpp's syncControllerInputs().
JNIEXPORT jboolean JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativePollSnapshot(JNIEnv *env, jclass, jlong handlePtr,
jintArray outButtons,
jfloatArray outAxes,
jfloatArray outHandPoses,
jbooleanArray outFlags) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle == nullptr) return JNI_FALSE;

xrimmersive::InputSnapshot snapshot = handle->session->pollSnapshot();

jint buttons[1] = {static_cast<jint>(snapshot.buttons)};
env->SetIntArrayRegion(outButtons, 0, 1, buttons);

jfloat axes[6] = {snapshot.leftX, snapshot.leftY, snapshot.rightX,
snapshot.rightY, snapshot.triggerL, snapshot.triggerR};
env->SetFloatArrayRegion(outAxes, 0, 6, axes);

jfloat poses[12] = {
snapshot.leftHandPosX, snapshot.leftHandPosY, snapshot.leftHandPosZ,
snapshot.leftHandFwdX, snapshot.leftHandFwdY, snapshot.leftHandFwdZ,
snapshot.rightHandPosX, snapshot.rightHandPosY, snapshot.rightHandPosZ,
snapshot.rightHandFwdX, snapshot.rightHandFwdY, snapshot.rightHandFwdZ,
};
env->SetFloatArrayRegion(outHandPoses, 0, 12, poses);

jboolean flags[3] = {
static_cast<jboolean>(snapshot.handPosesValid ? JNI_TRUE : JNI_FALSE),
static_cast<jboolean>(snapshot.pointerModeToggled ? JNI_TRUE : JNI_FALSE),
static_cast<jboolean>(snapshot.menuButtonHeld ? JNI_TRUE : JNI_FALSE),
};
env->SetBooleanArrayRegion(outFlags, 0, 3, flags);

return snapshot.quickMenuClicked ? JNI_TRUE : JNI_FALSE;
}

// Called from ImmersiveXrActivity's PixelCopy capture loop with the game's actual rendered
// frame (ARGB_8888 bitmap). Copies the pixels into the session's pending-frame buffer; the
// render thread uploads them to the GPU and draws them into the quad layer on its own.
JNIEXPORT void JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeSubmitFrame(JNIEnv *env, jclass, jlong handlePtr,
jobject bitmap) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle == nullptr) return;

AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) return;
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) return;

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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

static_cast<int32_t>(info.height));

AndroidBitmap_unlockPixels(env, bitmap);
}

JNIEXPORT void JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeSetQuadTransform(JNIEnv *, jclass, jlong handlePtr,
jfloat x, jfloat y, jfloat z,
jfloat width, jfloat height,
jfloat contentScaleX,
jfloat contentScaleY) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle == nullptr) return;
handle->session->setQuadTransform(x, y, z, width, height, contentScaleX, contentScaleY);
}

JNIEXPORT void JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeSetPassthroughEnabled(JNIEnv *, jclass,
jlong handlePtr,
jboolean enabled) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle == nullptr) return;
handle->session->setPassthroughEnabled(enabled == JNI_TRUE);
}

// See DirectGLBridge.kt / GLRenderer.XrFrameBridge — hardwareBuffer is the same
// android.hardware.HardwareBuffer GLRenderer is writing into on its own thread/context.
JNIEXPORT void JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeSetSharedGameBuffer(JNIEnv *env, jclass,
jlong handlePtr,
jobject hardwareBuffer) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle == nullptr || hardwareBuffer == nullptr) return;
AHardwareBuffer *buffer = AHardwareBuffer_fromHardwareBuffer(env, hardwareBuffer);
if (buffer == nullptr) return;
handle->session->setSharedGameBuffer(buffer);
}

// VulkanRenderer's zero-copy scanout path (DirectVulkanBridge.kt / VulkanXrFrameBridge) already
// has the game frame as a raw AHardwareBuffer* pointer (no android.hardware.HardwareBuffer Java
// object involved at all — VulkanRenderer.onUpdateWindowContent gets it straight from native
// GPUImage.getHardwareBufferPtr()), so this variant skips the Java-object-unwrapping step and
// reinterprets the pointer directly. Same underlying session method as the GL path above —
// setSharedGameBuffer()/importSharedBufferIfNeeded() don't care which renderer produced the
// buffer.
JNIEXPORT void JNICALL
Java_app_gamenative_ui_screen_xr_XrNative_nativeSetSharedGameBufferPtr(JNIEnv *, jclass,
jlong handlePtr,
jlong ahbPtr) {
auto *handle = reinterpret_cast<NativeHandle *>(handlePtr);
if (handle == nullptr || ahbPtr == 0) return;
handle->session->setSharedGameBuffer(reinterpret_cast<AHardwareBuffer *>(ahbPtr));
}

// GLRenderer's direct-render path (DirectGLBridge.kt) needs to import the same HardwareBuffer as
// a GL texture in ITS OWN thread/context (a different context than the XR session's own thread
// above) — the public Android SDK has no Java binding for eglGetNativeClientBufferANDROID /
// eglCreateImageKHR (GLES11Ext's Java binding still takes a raw java.nio.Buffer, a legacy
// pre-HardwareBuffer calling convention), so this stateless helper does the same native EGLImage
// import dance and hands back a plain GL texture name the Java side can use normally. Must be
// called with an EGL context already current on the calling thread.
JNIEXPORT jint JNICALL
Java_com_winlator_renderer_GLHardwareBufferImporter_importAsTexture(JNIEnv *env, jclass,
jobject hardwareBuffer) {
if (hardwareBuffer == nullptr) return 0;
AHardwareBuffer *buffer = AHardwareBuffer_fromHardwareBuffer(env, hardwareBuffer);
if (buffer == nullptr) return 0;

EGLDisplay display = eglGetCurrentDisplay();
if (display == EGL_NO_DISPLAY) {
LOGE("GLHardwareBufferImporter: no current EGL display on this thread");
return 0;
}

EGLClientBuffer clientBuffer = eglGetNativeClientBufferANDROID(buffer);
if (clientBuffer == nullptr) {
LOGE("GLHardwareBufferImporter: eglGetNativeClientBufferANDROID failed");
return 0;
}

const EGLint attrs[] = {EGL_NONE};
EGLImageKHR image = eglCreateImageKHR(display, EGL_NO_CONTEXT, EGL_NATIVE_BUFFER_ANDROID,
clientBuffer, attrs);
if (image == EGL_NO_IMAGE_KHR) {
LOGE("GLHardwareBufferImporter: eglCreateImageKHR failed (0x%x)", eglGetError());
return 0;
}

GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, image);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);

// The texture keeps the buffer's contents alive on the driver side; the EGLImage itself can
// be destroyed right after the glEGLImageTargetTexture2DOES call, same pattern used by
// Camera2/MediaCodec consumers.
eglDestroyImageKHR(display, image);

LOGI("GLHardwareBufferImporter: imported HardwareBuffer as GL texture %u", texture);
return static_cast<jint>(texture);
}

} // extern "C"
Loading