diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index eff1ae7158..708e0e1daf 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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 {
@@ -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 {
@@ -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")
+
// Winlator
implementation(libs.bundles.winlator)
implementation(libs.libarchive.android)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 62ff02d129..94bf5a7201 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,20 @@
android:glEsVersion="0x00020000"
android:required="true" />
+
+
+
+
+
+
@@ -16,6 +30,7 @@
+
@@ -89,6 +104,53 @@
+
+
+
+
+
+
+
+
+
+#include
+#include
+#include
+
+#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(handle);
+}
+
+JNIEXPORT void JNICALL
+Java_app_gamenative_ui_screen_xr_XrNative_nativeRequestStop(JNIEnv *, jclass, jlong handlePtr) {
+ auto *handle = reinterpret_cast(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(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(handlePtr);
+ if (handle == nullptr) return JNI_FALSE;
+
+ xrimmersive::InputSnapshot snapshot = handle->session->pollSnapshot();
+
+ jint buttons[1] = {static_cast(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(snapshot.handPosesValid ? JNI_TRUE : JNI_FALSE),
+ static_cast(snapshot.pointerModeToggled ? JNI_TRUE : JNI_FALSE),
+ static_cast(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(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(pixels), static_cast(info.width),
+ static_cast(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(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(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(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(handlePtr);
+ if (handle == nullptr || ahbPtr == 0) return;
+ handle->session->setSharedGameBuffer(reinterpret_cast(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(texture);
+}
+
+} // extern "C"
diff --git a/app/src/main/cpp/xrimmersive/xr_immersive.cpp b/app/src/main/cpp/xrimmersive/xr_immersive.cpp
new file mode 100644
index 0000000000..b11fddb65a
--- /dev/null
+++ b/app/src/main/cpp/xrimmersive/xr_immersive.cpp
@@ -0,0 +1,1218 @@
+#include "xr_immersive.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define LOG_TAG "xrimmersive"
+#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 xrimmersive {
+
+namespace {
+
+bool XrCheck(XrResult result, const char *what) {
+ if (XR_FAILED(result)) {
+ LOGE("%s failed: %d", what, static_cast(result));
+ return false;
+ }
+ return true;
+}
+
+bool IsInstanceExtensionSupported(const char *name) {
+ uint32_t count = 0;
+ xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr);
+ std::vector properties(count, {XR_TYPE_EXTENSION_PROPERTIES});
+ xrEnumerateInstanceExtensionProperties(nullptr, count, &count, properties.data());
+ for (const auto &property : properties) {
+ if (std::strcmp(property.extensionName, name) == 0) return true;
+ }
+ return false;
+}
+
+XrPosef IdentityPose() {
+ XrPosef pose;
+ pose.orientation.x = 0.0f;
+ pose.orientation.y = 0.0f;
+ pose.orientation.z = 0.0f;
+ pose.orientation.w = 1.0f;
+ pose.position.x = 0.0f;
+ pose.position.y = 0.0f;
+ pose.position.z = 0.0f;
+ return pose;
+}
+
+} // namespace
+
+bool XrImmersiveSession::initialize(JavaVM *vm, jobject activityRef) {
+ vm_ = vm;
+ activityRef_ = activityRef;
+
+ thread_ = std::thread([this] { runLoop(); });
+ return true;
+}
+
+void XrImmersiveSession::requestStop() {
+ stopRequested_.store(true);
+}
+
+void XrImmersiveSession::join() {
+ if (thread_.joinable()) {
+ thread_.join();
+ }
+}
+
+InputSnapshot XrImmersiveSession::pollSnapshot() {
+ std::lock_guard lock(snapshotMutex_);
+ InputSnapshot result = snapshot_;
+ // Rising-edge flags are consumed on read.
+ snapshot_.quickMenuClicked = false;
+ snapshot_.pointerModeToggled = false;
+ return result;
+}
+
+void XrImmersiveSession::submitFrame(const uint8_t *rgbaPixels, int32_t width, int32_t height) {
+ std::lock_guard lock(frameMutex_);
+ const size_t byteCount = static_cast(width) * static_cast(height) * 4;
+ pendingFramePixels_.resize(byteCount);
+ std::memcpy(pendingFramePixels_.data(), rgbaPixels, byteCount);
+ pendingFrameWidth_ = width;
+ pendingFrameHeight_ = height;
+ hasPendingFrame_ = true;
+}
+
+void XrImmersiveSession::setSharedGameBuffer(AHardwareBuffer *buffer) {
+ std::lock_guard lock(sharedBufferMutex_);
+ if (pendingSharedBuffer_ != nullptr) {
+ AHardwareBuffer_release(pendingSharedBuffer_);
+ }
+ AHardwareBuffer_acquire(buffer);
+ pendingSharedBuffer_ = buffer;
+ sharedBufferChanged_ = true;
+}
+
+// Must run on the render thread (this thread) — glEGLImageTargetTexture2DOES needs this
+// thread's EGL context current. AHardwareBuffer's underlying memory is what's actually shared
+// across contexts/processes; the EGLImage/texture wrapping it is per-context, so importing
+// again here (GLRenderer already imported the same buffer once, into its own context) is
+// expected and correct, not a duplicate/wasted step.
+void XrImmersiveSession::importSharedBufferIfNeeded() {
+ AHardwareBuffer *buffer = nullptr;
+ {
+ std::lock_guard lock(sharedBufferMutex_);
+ if (!sharedBufferChanged_) return;
+ buffer = pendingSharedBuffer_;
+ sharedBufferChanged_ = false;
+ }
+ if (buffer == nullptr) return;
+
+ if (sharedGameImage_ != EGL_NO_IMAGE_KHR) {
+ eglDestroyImageKHR(eglDisplay_, sharedGameImage_);
+ sharedGameImage_ = EGL_NO_IMAGE_KHR;
+ }
+
+ EGLClientBuffer clientBuffer = eglGetNativeClientBufferANDROID(buffer);
+ if (clientBuffer == nullptr) {
+ LOGE("Immersive direct-render: eglGetNativeClientBufferANDROID failed");
+ return;
+ }
+
+ const EGLint attrs[] = {EGL_NONE};
+ sharedGameImage_ = eglCreateImageKHR(eglDisplay_, EGL_NO_CONTEXT, EGL_NATIVE_BUFFER_ANDROID,
+ clientBuffer, attrs);
+ if (sharedGameImage_ == EGL_NO_IMAGE_KHR) {
+ LOGE("Immersive direct-render: eglCreateImageKHR failed (0x%x)", eglGetError());
+ return;
+ }
+
+ if (sharedGameTexture_ == 0) {
+ glGenTextures(1, &sharedGameTexture_);
+ }
+ glBindTexture(GL_TEXTURE_2D, sharedGameTexture_);
+ glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, sharedGameImage_);
+ 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);
+ hasSharedGameTexture_ = true;
+ LOGI("Immersive direct-render: shared game buffer imported into XR thread's context (texture=%u)",
+ sharedGameTexture_);
+}
+
+void XrImmersiveSession::runLoop() {
+ if (!setupInstanceAndSession()) {
+ LOGE("OpenXR setup failed, aborting immersive session thread");
+ teardown();
+ return;
+ }
+
+ while (!stopRequested_.load()) {
+ pollXrEvents();
+
+ if (!sessionRunning_) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ continue;
+ }
+
+ XrFrameWaitInfo waitInfo{XR_TYPE_FRAME_WAIT_INFO};
+ XrFrameState frameState{XR_TYPE_FRAME_STATE};
+ if (!XrCheck(xrWaitFrame(session_, &waitInfo, &frameState), "xrWaitFrame")) break;
+
+ XrFrameBeginInfo beginInfo{XR_TYPE_FRAME_BEGIN_INFO};
+ if (!XrCheck(xrBeginFrame(session_, &beginInfo), "xrBeginFrame")) break;
+
+ applyPendingPassthroughState();
+ syncControllerInputs(frameState.predictedDisplayTime);
+
+ if (frameState.shouldRender) {
+ renderFrame();
+ submitQuadLayer(frameState.predictedDisplayTime, localSpace_, swapchain_,
+ kSwapchainWidth, kSwapchainHeight, true);
+ } else {
+ XrFrameEndInfo endInfo{XR_TYPE_FRAME_END_INFO};
+ endInfo.displayTime = frameState.predictedDisplayTime;
+ endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
+ endInfo.layerCount = 0;
+ endInfo.layers = nullptr;
+ xrEndFrame(session_, &endInfo);
+ }
+ }
+
+ teardown();
+}
+
+bool XrImmersiveSession::setupInstanceAndSession() {
+ // Android requires the loader to be explicitly initialized before xrCreateInstance.
+ PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
+ xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
+ reinterpret_cast(&initializeLoader));
+ if (initializeLoader == nullptr) {
+ LOGE("xrInitializeLoaderKHR not available");
+ return false;
+ }
+
+ XrLoaderInitInfoAndroidKHR loaderInitInfo{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
+ loaderInitInfo.applicationVM = vm_;
+ loaderInitInfo.applicationContext = activityRef_;
+ if (!XrCheck(initializeLoader(reinterpret_cast(&loaderInitInfo)),
+ "xrInitializeLoaderKHR")) {
+ return false;
+ }
+
+ std::vector extensions = {
+ XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
+ XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
+ };
+ passthroughExtensionAvailable_ = IsInstanceExtensionSupported(XR_FB_PASSTHROUGH_EXTENSION_NAME);
+ if (passthroughExtensionAvailable_) {
+ extensions.push_back(XR_FB_PASSTHROUGH_EXTENSION_NAME);
+ } else {
+ LOGI("XR_FB_passthrough not supported by this runtime — passthrough toggle will no-op");
+ }
+
+ // Perf/scheduling extensions GameNativeXR's libxr.so uses (confirmed via strings on the
+ // binary — standard OpenXR extensions, nothing proprietary) that this module never
+ // requested at all: without XR_EXT_performance_settings the runtime applies its own default
+ // (often conservative) CPU/GPU clock levels for the session; without
+ // XR_KHR_android_thread_settings, Horizon OS's scheduler has no hint which thread is the XR
+ // render thread and may not prioritize it; XR_FB_display_refresh_rate lets us target a
+ // lower refresh rate (less frame-budget pressure) instead of whatever the system default is.
+ perfSettingsExtensionAvailable_ = IsInstanceExtensionSupported(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
+ if (perfSettingsExtensionAvailable_) extensions.push_back(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
+ threadSettingsExtensionAvailable_ = IsInstanceExtensionSupported(XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME);
+ if (threadSettingsExtensionAvailable_) extensions.push_back(XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME);
+ refreshRateExtensionAvailable_ = IsInstanceExtensionSupported(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
+ if (refreshRateExtensionAvailable_) extensions.push_back(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
+
+ XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
+ androidInfo.applicationVM = vm_;
+ androidInfo.applicationActivity = activityRef_;
+
+ XrInstanceCreateInfo createInfo{XR_TYPE_INSTANCE_CREATE_INFO};
+ createInfo.next = &androidInfo;
+ std::strncpy(createInfo.applicationInfo.applicationName, "GameNative",
+ XR_MAX_APPLICATION_NAME_SIZE - 1);
+ createInfo.applicationInfo.applicationVersion = 1;
+ std::strncpy(createInfo.applicationInfo.engineName, "GameNative", XR_MAX_ENGINE_NAME_SIZE - 1);
+ createInfo.applicationInfo.engineVersion = 1;
+ createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION;
+ createInfo.enabledExtensionCount = static_cast(extensions.size());
+ createInfo.enabledExtensionNames = extensions.data();
+
+ if (!XrCheck(xrCreateInstance(&createInfo, &instance_), "xrCreateInstance")) return false;
+
+ XrSystemGetInfo systemGetInfo{XR_TYPE_SYSTEM_GET_INFO};
+ systemGetInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
+ if (!XrCheck(xrGetSystem(instance_, &systemGetInfo, &systemId_), "xrGetSystem")) return false;
+
+ // Passthrough needs ALPHA_BLEND — but requesting a blend mode xrEndFrame doesn't list as
+ // supported for this view config is a validation error (this runtime rejected every frame
+ // with xrEndFrame failing -1 once passthrough was toggled on, until this check was added).
+ uint32_t blendModeCount = 0;
+ xrEnumerateEnvironmentBlendModes(instance_, systemId_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
+ 0, &blendModeCount, nullptr);
+ if (blendModeCount > 0) {
+ std::vector blendModes(blendModeCount);
+ xrEnumerateEnvironmentBlendModes(instance_, systemId_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
+ blendModeCount, &blendModeCount, blendModes.data());
+ for (auto mode : blendModes) {
+ if (mode == XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND) alphaBlendSupported_ = true;
+ }
+ }
+ if (!alphaBlendSupported_) {
+ LOGI("XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND not supported by this runtime — passthrough toggle will no-op");
+ }
+
+ PFN_xrGetOpenGLESGraphicsRequirementsKHR getGraphicsRequirements = nullptr;
+ xrGetInstanceProcAddr(instance_, "xrGetOpenGLESGraphicsRequirementsKHR",
+ reinterpret_cast(&getGraphicsRequirements));
+ XrGraphicsRequirementsOpenGLESKHR graphicsRequirements{XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
+ if (getGraphicsRequirements != nullptr) {
+ getGraphicsRequirements(instance_, systemId_, &graphicsRequirements);
+ }
+
+ // --- EGL context, dedicated to this session (not yet shared with the app's own
+ // GLRenderer/DXVK-facing surface — see the header comment / plan follow-ups). ---
+ eglDisplay_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
+ EGLint eglMajor, eglMinor;
+ eglInitialize(eglDisplay_, &eglMajor, &eglMinor);
+ eglBindAPI(EGL_OPENGL_ES_API);
+
+ const EGLint configAttribs[] = {
+ EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR,
+ EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
+ EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
+ EGL_NONE,
+ };
+ EGLint numConfigs = 0;
+ eglChooseConfig(eglDisplay_, configAttribs, &eglConfig_, 1, &numConfigs);
+ if (numConfigs == 0) {
+ LOGE("eglChooseConfig found no matching config");
+ return false;
+ }
+
+ const EGLint contextAttribs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
+ eglContext_ = eglCreateContext(eglDisplay_, eglConfig_, EGL_NO_CONTEXT, contextAttribs);
+
+ const EGLint pbufferAttribs[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
+ eglPbufferSurface_ = eglCreatePbufferSurface(eglDisplay_, eglConfig_, pbufferAttribs);
+ eglMakeCurrent(eglDisplay_, eglPbufferSurface_, eglPbufferSurface_, eglContext_);
+
+ XrGraphicsBindingOpenGLESAndroidKHR graphicsBinding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
+ graphicsBinding.display = eglDisplay_;
+ graphicsBinding.config = eglConfig_;
+ graphicsBinding.context = eglContext_;
+
+ XrSessionCreateInfo sessionCreateInfo{XR_TYPE_SESSION_CREATE_INFO};
+ sessionCreateInfo.next = &graphicsBinding;
+ sessionCreateInfo.systemId = systemId_;
+ if (!XrCheck(xrCreateSession(instance_, &sessionCreateInfo, &session_), "xrCreateSession")) {
+ return false;
+ }
+
+ // Perf levels: request BOOST for both CPU and GPU domains — this game is running Box64
+ // (x86 emulation) + Wine + our own screen-scraping capture on top of everything else, so
+ // there's no reason to accept the runtime's own default (often more conservative) clock
+ // levels for an immersive session that specifically exists to maximize performance.
+ if (perfSettingsExtensionAvailable_) {
+ PFN_xrPerfSettingsSetPerformanceLevelEXT setPerfLevel = nullptr;
+ xrGetInstanceProcAddr(instance_, "xrPerfSettingsSetPerformanceLevelEXT",
+ reinterpret_cast(&setPerfLevel));
+ if (setPerfLevel != nullptr) {
+ // BOOST is documented as a short-burst level, not meant for continuous sustained
+ // rendering — requesting it for an entire session risks hitting thermal limits
+ // sooner, which the runtime then compensates for by throttling clocks back down,
+ // net WORSE and less consistent than just requesting the sustained level up front
+ // (confirmed by testing: frame pacing got worse after BOOST was added, not better).
+ XrCheck(setPerfLevel(session_, XR_PERF_SETTINGS_DOMAIN_CPU_EXT, XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT),
+ "xrPerfSettingsSetPerformanceLevelEXT(CPU)");
+ XrCheck(setPerfLevel(session_, XR_PERF_SETTINGS_DOMAIN_GPU_EXT, XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT),
+ "xrPerfSettingsSetPerformanceLevelEXT(GPU)");
+ }
+ }
+
+ // Thread hint: tells Horizon OS's scheduler which OS thread is the XR render thread (this
+ // one — setupInstanceAndSession() runs on the dedicated thread created in initialize(), so
+ // gettid() here is correct) so it can prioritize it. Without this the scheduler has no
+ // signal that this thread's timing matters for frame delivery.
+ if (threadSettingsExtensionAvailable_) {
+ PFN_xrSetAndroidApplicationThreadKHR setThread = nullptr;
+ xrGetInstanceProcAddr(instance_, "xrSetAndroidApplicationThreadKHR",
+ reinterpret_cast(&setThread));
+ if (setThread != nullptr) {
+ XrCheck(setThread(session_, XR_ANDROID_THREAD_TYPE_RENDERER_MAIN_KHR, gettid()),
+ "xrSetAndroidApplicationThreadKHR");
+ }
+ }
+
+ // Target 72Hz rather than whatever higher default the runtime picks (90/120Hz) — matches
+ // GameNativeXR's own default. Less frame-budget pressure for a pipeline (game render +
+ // PixelCopy readback + composite + texture upload) that doesn't have headroom to spare.
+ if (refreshRateExtensionAvailable_) {
+ PFN_xrRequestDisplayRefreshRateFB requestRefreshRate = nullptr;
+ xrGetInstanceProcAddr(instance_, "xrRequestDisplayRefreshRateFB",
+ reinterpret_cast(&requestRefreshRate));
+ if (requestRefreshRate != nullptr) {
+ XrCheck(requestRefreshRate(session_, 72.0f), "xrRequestDisplayRefreshRateFB");
+ }
+ }
+
+ XrReferenceSpaceCreateInfo spaceCreateInfo{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
+ spaceCreateInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
+ spaceCreateInfo.poseInReferenceSpace = IdentityPose();
+ if (!XrCheck(xrCreateReferenceSpace(session_, &spaceCreateInfo, &localSpace_),
+ "xrCreateReferenceSpace")) {
+ return false;
+ }
+
+ uint32_t formatCount = 0;
+ xrEnumerateSwapchainFormats(session_, 0, &formatCount, nullptr);
+ std::vector formats(formatCount);
+ xrEnumerateSwapchainFormats(session_, formatCount, &formatCount, formats.data());
+ int64_t chosenFormat = formats.empty() ? 0x8058 /* GL_RGBA8 */ : formats[0];
+ for (int64_t f : formats) {
+ if (f == 0x8058) {
+ chosenFormat = f;
+ break;
+ }
+ }
+
+ XrSwapchainCreateInfo swapchainCreateInfo{XR_TYPE_SWAPCHAIN_CREATE_INFO};
+ swapchainCreateInfo.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
+ swapchainCreateInfo.format = chosenFormat;
+ swapchainCreateInfo.sampleCount = 1;
+ swapchainCreateInfo.width = kSwapchainWidth;
+ swapchainCreateInfo.height = kSwapchainHeight;
+ swapchainCreateInfo.faceCount = 1;
+ swapchainCreateInfo.arraySize = 1;
+ swapchainCreateInfo.mipCount = 1;
+ if (!XrCheck(xrCreateSwapchain(session_, &swapchainCreateInfo, &swapchain_), "xrCreateSwapchain")) {
+ return false;
+ }
+
+ uint32_t imageCount = 0;
+ xrEnumerateSwapchainImages(swapchain_, 0, &imageCount, nullptr);
+ swapchainImages_.resize(imageCount);
+ for (auto &image : swapchainImages_) image.type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
+ xrEnumerateSwapchainImages(
+ swapchain_, imageCount, &imageCount,
+ reinterpret_cast(swapchainImages_.data()));
+
+ glGenFramebuffers(1, &framebuffer_);
+
+ // --- Input: action set + Meta Quest Touch controller bindings. ---
+ XrActionSetCreateInfo actionSetInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
+ std::strncpy(actionSetInfo.actionSetName, "gameplay", XR_MAX_ACTION_SET_NAME_SIZE - 1);
+ std::strncpy(actionSetInfo.localizedActionSetName, "Gameplay", XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE - 1);
+ actionSetInfo.priority = 0;
+ if (!XrCheck(xrCreateActionSet(instance_, &actionSetInfo, &actionSet_), "xrCreateActionSet")) {
+ return false;
+ }
+
+ auto createAction = [this](const char *name, XrActionType type, XrAction *out) {
+ XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
+ std::strncpy(info.actionName, name, XR_MAX_ACTION_NAME_SIZE - 1);
+ std::strncpy(info.localizedActionName, name, XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
+ info.actionType = type;
+ info.countSubactionPaths = 0;
+ XrCheck(xrCreateAction(actionSet_, &info, out), name);
+ };
+
+ createAction("button_x", XR_ACTION_TYPE_BOOLEAN_INPUT, &buttonXAction_);
+ createAction("button_y", XR_ACTION_TYPE_BOOLEAN_INPUT, &buttonYAction_);
+ createAction("button_a", XR_ACTION_TYPE_BOOLEAN_INPUT, &buttonAAction_);
+ createAction("button_b", XR_ACTION_TYPE_BOOLEAN_INPUT, &buttonBAction_);
+ createAction("squeeze_left", XR_ACTION_TYPE_FLOAT_INPUT, &squeezeLAction_);
+ createAction("squeeze_right", XR_ACTION_TYPE_FLOAT_INPUT, &squeezeRAction_);
+ createAction("trigger_left", XR_ACTION_TYPE_FLOAT_INPUT, &triggerLAction_);
+ createAction("trigger_right", XR_ACTION_TYPE_FLOAT_INPUT, &triggerRAction_);
+ createAction("thumbstick_left", XR_ACTION_TYPE_VECTOR2F_INPUT, &thumbstickLAction_);
+ createAction("thumbstick_right", XR_ACTION_TYPE_VECTOR2F_INPUT, &thumbstickRAction_);
+ createAction("thumbstick_left_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &thumbstickLClickAction_);
+ createAction("thumbstick_right_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &thumbstickRClickAction_);
+ createAction("menu_left", XR_ACTION_TYPE_BOOLEAN_INPUT, &menuLAction_);
+ createAction("aim_pose_left", XR_ACTION_TYPE_POSE_INPUT, &aimPoseLeftAction_);
+ createAction("aim_pose_right", XR_ACTION_TYPE_POSE_INPUT, &aimPoseRightAction_);
+
+ auto path = [this](const char *p) {
+ XrPath result = XR_NULL_PATH;
+ xrStringToPath(instance_, p, &result);
+ return result;
+ };
+
+ std::vector bindings = {
+ {buttonXAction_, path("/user/hand/left/input/x/click")},
+ {buttonYAction_, path("/user/hand/left/input/y/click")},
+ {buttonAAction_, path("/user/hand/right/input/a/click")},
+ {buttonBAction_, path("/user/hand/right/input/b/click")},
+ {squeezeLAction_, path("/user/hand/left/input/squeeze/value")},
+ {squeezeRAction_, path("/user/hand/right/input/squeeze/value")},
+ {triggerLAction_, path("/user/hand/left/input/trigger/value")},
+ {triggerRAction_, path("/user/hand/right/input/trigger/value")},
+ {thumbstickLAction_, path("/user/hand/left/input/thumbstick")},
+ {thumbstickRAction_, path("/user/hand/right/input/thumbstick")},
+ {thumbstickLClickAction_, path("/user/hand/left/input/thumbstick/click")},
+ {thumbstickRClickAction_, path("/user/hand/right/input/thumbstick/click")},
+ {menuLAction_, path("/user/hand/left/input/menu/click")},
+ {aimPoseLeftAction_, path("/user/hand/left/input/aim/pose")},
+ {aimPoseRightAction_, path("/user/hand/right/input/aim/pose")},
+ };
+
+ XrInteractionProfileSuggestedBinding suggestedBinding{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
+ suggestedBinding.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
+ suggestedBinding.countSuggestedBindings = static_cast(bindings.size());
+ suggestedBinding.suggestedBindings = bindings.data();
+ XrCheck(xrSuggestInteractionProfileBindings(instance_, &suggestedBinding),
+ "xrSuggestInteractionProfileBindings");
+
+ XrSessionActionSetsAttachInfo attachInfo{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
+ attachInfo.countActionSets = 1;
+ attachInfo.actionSets = &actionSet_;
+ XrCheck(xrAttachSessionActionSets(session_, &attachInfo), "xrAttachSessionActionSets");
+
+ XrActionSpaceCreateInfo aimSpaceInfo{XR_TYPE_ACTION_SPACE_CREATE_INFO};
+ aimSpaceInfo.poseInActionSpace = IdentityPose();
+ aimSpaceInfo.action = aimPoseLeftAction_;
+ XrCheck(xrCreateActionSpace(session_, &aimSpaceInfo, &aimSpaceLeft_), "xrCreateActionSpace(left aim)");
+ aimSpaceInfo.action = aimPoseRightAction_;
+ XrCheck(xrCreateActionSpace(session_, &aimSpaceInfo, &aimSpaceRight_), "xrCreateActionSpace(right aim)");
+
+ if (passthroughExtensionAvailable_) {
+ setupPassthrough();
+ }
+
+ LOGI("OpenXR immersive session initialized (%dx%d quad, %u swapchain images)",
+ kSwapchainWidth, kSwapchainHeight, imageCount);
+ return true;
+}
+
+void XrImmersiveSession::pollXrEvents() {
+ XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER};
+ while (instance_ != XR_NULL_HANDLE && xrPollEvent(instance_, &event) == XR_SUCCESS) {
+ if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
+ auto *stateEvent = reinterpret_cast(&event);
+ sessionState_ = stateEvent->state;
+ LOGI("OpenXR session state changed: %d", static_cast(sessionState_));
+ switch (sessionState_) {
+ case XR_SESSION_STATE_READY: {
+ XrSessionBeginInfo beginInfo{XR_TYPE_SESSION_BEGIN_INFO};
+ beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
+ XrCheck(xrBeginSession(session_, &beginInfo), "xrBeginSession");
+ sessionRunning_ = true;
+ break;
+ }
+ case XR_SESSION_STATE_STOPPING:
+ xrEndSession(session_);
+ sessionRunning_ = false;
+ break;
+ case XR_SESSION_STATE_EXITING:
+ case XR_SESSION_STATE_LOSS_PENDING:
+ stopRequested_.store(true);
+ break;
+ default:
+ break;
+ }
+ }
+ event.type = XR_TYPE_EVENT_DATA_BUFFER;
+ }
+}
+
+namespace {
+constexpr char kQuadVertexShader[] =
+ "#version 300 es\n"
+ "layout(location = 0) in vec2 aPosition;\n"
+ "layout(location = 1) in vec2 aTexCoord;\n"
+ "out vec2 vTexCoord;\n"
+ "void main() {\n"
+ " vTexCoord = aTexCoord;\n"
+ " gl_Position = vec4(aPosition, 0.0, 1.0);\n"
+ "}\n";
+
+constexpr char kQuadFragmentShader[] =
+ "#version 300 es\n"
+ "precision mediump float;\n"
+ "in vec2 vTexCoord;\n"
+ "out vec4 fragColor;\n"
+ "uniform sampler2D uTexture;\n"
+ "void main() {\n"
+ " fragColor = texture(uTexture, vTexCoord);\n"
+ "}\n";
+
+// Direct-render path: uGameTexture is the shared-buffer texture GLRenderer writes into on its
+// own thread (always opaque, drawn as the base); uOverlayTexture is the same CPU-uploaded
+// menu/HUD layer as the non-direct path, alpha-blended on top — same visual result as the
+// non-direct path's Kotlin-side Canvas composite, just with the base layer sourced from a
+// shared GPU buffer instead of a PixelCopy'd CPU bitmap.
+//
+// uContentScale: the quad itself is grown by a margin band (pointer-mode resize/move handles
+// live there — see POINTER_BORDER_MARGIN_METERS in ImmersiveXrActivity.kt), but uGameTexture is
+// the raw game frame at its own native resolution with no margin baked in — sampling it at
+// vTexCoord directly (spanning the WHOLE, margin-grown quad) would stretch it into the margin.
+// uContentScale is content-half-extent / quad-half-extent per axis (< 1.0 whenever a margin is
+// present); remapping vTexCoord by it recovers the sub-rectangle of the quad's UV space that is
+// actually "the game", and anything outside that rectangle (the margin) is treated as if the
+// game layer were fully transparent there — only uOverlayTexture (which DOES span the whole
+// quad, including the margin — same bitmap the non-direct path already draws its handles/cursor
+// into) shows through, at ITS OWN alpha, matching the non-direct path's compositing model.
+constexpr char kDirectQuadFragmentShader[] =
+ "#version 300 es\n"
+ "precision mediump float;\n"
+ "in vec2 vTexCoord;\n"
+ "out vec4 fragColor;\n"
+ "uniform sampler2D uGameTexture;\n"
+ "uniform sampler2D uOverlayTexture;\n"
+ "uniform vec2 uContentScale;\n"
+ "void main() {\n"
+ " vec2 gameUv = (vTexCoord - 0.5) / uContentScale + 0.5;\n"
+ " bool insideGame = gameUv.x >= 0.0 && gameUv.x <= 1.0 && gameUv.y >= 0.0 && gameUv.y <= 1.0;\n"
+ " vec4 game = insideGame ? texture(uGameTexture, gameUv) : vec4(0.0);\n"
+ " vec4 overlay = texture(uOverlayTexture, vTexCoord);\n"
+ " vec3 rgb = mix(game.rgb, overlay.rgb, overlay.a);\n"
+ " float alpha = insideGame ? 1.0 : overlay.a;\n"
+ " fragColor = vec4(rgb, alpha);\n"
+ "}\n";
+
+GLuint CompileShader(GLenum type, const char *source) {
+ GLuint shader = glCreateShader(type);
+ glShaderSource(shader, 1, &source, nullptr);
+ glCompileShader(shader);
+ GLint compiled = 0;
+ glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
+ if (!compiled) {
+ char log[512];
+ glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
+ LOGE("Shader compile error: %s", log);
+ glDeleteShader(shader);
+ return 0;
+ }
+ return shader;
+}
+} // namespace
+
+void XrImmersiveSession::ensureQuadGeometryAndShader() {
+ if (quadProgram_ != 0) return;
+
+ GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, kQuadVertexShader);
+ GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, kQuadFragmentShader);
+ if (vertexShader == 0 || fragmentShader == 0) return;
+
+ quadProgram_ = glCreateProgram();
+ glAttachShader(quadProgram_, vertexShader);
+ glAttachShader(quadProgram_, fragmentShader);
+ glLinkProgram(quadProgram_);
+ GLint linked = 0;
+ glGetProgramiv(quadProgram_, GL_LINK_STATUS, &linked);
+ if (!linked) {
+ char log[512];
+ glGetProgramInfoLog(quadProgram_, sizeof(log), nullptr, log);
+ LOGE("Shader link error: %s", log);
+ }
+ glDeleteShader(vertexShader);
+ glDeleteShader(fragmentShader);
+
+ quadPositionLoc_ = 0;
+ quadTexCoordLoc_ = 1;
+ quadSamplerLoc_ = glGetUniformLocation(quadProgram_, "uTexture");
+
+ // Full-screen quad (2 triangles as a strip): xy in clip space, uv in texture space.
+ // v=0 maps to the bitmap's first (top) row, v=1 to its last (bottom) row — Android
+ // Bitmaps are stored top-down, so this should show right-side-up; flip here if a
+ // device test shows it upside down.
+ const float vertices[] = {
+ // x, y, u, v
+ -1.0f, -1.0f, 0.0f, 1.0f,
+ 1.0f, -1.0f, 1.0f, 1.0f,
+ -1.0f, 1.0f, 0.0f, 0.0f,
+ 1.0f, 1.0f, 1.0f, 0.0f,
+ };
+ glGenBuffers(1, &quadVbo_);
+ glBindBuffer(GL_ARRAY_BUFFER, quadVbo_);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
+ glBindBuffer(GL_ARRAY_BUFFER, 0);
+
+ glGenTextures(1, &gameTexture_);
+ glBindTexture(GL_TEXTURE_2D, gameTexture_);
+ 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);
+
+ GLuint directVertexShader = CompileShader(GL_VERTEX_SHADER, kQuadVertexShader);
+ GLuint directFragmentShader = CompileShader(GL_FRAGMENT_SHADER, kDirectQuadFragmentShader);
+ if (directVertexShader != 0 && directFragmentShader != 0) {
+ directQuadProgram_ = glCreateProgram();
+ glAttachShader(directQuadProgram_, directVertexShader);
+ glAttachShader(directQuadProgram_, directFragmentShader);
+ glLinkProgram(directQuadProgram_);
+ GLint directLinked = 0;
+ glGetProgramiv(directQuadProgram_, GL_LINK_STATUS, &directLinked);
+ if (!directLinked) {
+ char log[512];
+ glGetProgramInfoLog(directQuadProgram_, sizeof(log), nullptr, log);
+ LOGE("Direct quad shader link error: %s", log);
+ }
+ directQuadPositionLoc_ = 0;
+ directQuadTexCoordLoc_ = 1;
+ directGameSamplerLoc_ = glGetUniformLocation(directQuadProgram_, "uGameTexture");
+ directOverlaySamplerLoc_ = glGetUniformLocation(directQuadProgram_, "uOverlayTexture");
+ directContentScaleLoc_ = glGetUniformLocation(directQuadProgram_, "uContentScale");
+ }
+ glDeleteShader(directVertexShader);
+ glDeleteShader(directFragmentShader);
+}
+
+void XrImmersiveSession::uploadPendingGameFrameLocked() {
+ std::lock_guard lock(frameMutex_);
+ if (!hasPendingFrame_) return;
+
+ glBindTexture(GL_TEXTURE_2D, gameTexture_);
+ if (pendingFrameWidth_ != gameTextureWidth_ || pendingFrameHeight_ != gameTextureHeight_) {
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, pendingFrameWidth_, pendingFrameHeight_, 0,
+ GL_RGBA, GL_UNSIGNED_BYTE, pendingFramePixels_.data());
+ gameTextureWidth_ = pendingFrameWidth_;
+ gameTextureHeight_ = pendingFrameHeight_;
+ } else {
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, pendingFrameWidth_, pendingFrameHeight_, GL_RGBA,
+ GL_UNSIGNED_BYTE, pendingFramePixels_.data());
+ }
+ glBindTexture(GL_TEXTURE_2D, 0);
+ hasPendingFrame_ = false;
+}
+
+void XrImmersiveSession::renderFrame() {
+ XrSwapchainImageAcquireInfo acquireInfo{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
+ uint32_t imageIndex = 0;
+ if (!XrCheck(xrAcquireSwapchainImage(swapchain_, &acquireInfo, &imageIndex),
+ "xrAcquireSwapchainImage")) {
+ return;
+ }
+
+ XrSwapchainImageWaitInfo waitInfo{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
+ waitInfo.timeout = XR_INFINITE_DURATION;
+ xrWaitSwapchainImage(swapchain_, &waitInfo);
+
+ ensureQuadGeometryAndShader();
+ importSharedBufferIfNeeded();
+ uploadPendingGameFrameLocked();
+
+ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+ swapchainImages_[imageIndex].image, 0);
+ glViewport(0, 0, kSwapchainWidth, kSwapchainHeight);
+
+ if (hasSharedGameTexture_ && directQuadProgram_ != 0) {
+ // GLRenderer wrote this frame's game image straight into sharedGameTexture_ on its own
+ // thread (zero CPU copy) — gameTexture_ here now carries just the overlay (menu/HUD),
+ // still fed the same way as the non-direct path (uploadPendingGameFrameLocked, above).
+ static int directFrameLogCounter = 0;
+ if (++directFrameLogCounter % 300 == 1) {
+ LOGI("Immersive direct-render: compositing shared game texture + overlay (frame #%d)",
+ directFrameLogCounter);
+ }
+ glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glUseProgram(directQuadProgram_);
+ glBindBuffer(GL_ARRAY_BUFFER, quadVbo_);
+ glEnableVertexAttribArray(directQuadPositionLoc_);
+ glVertexAttribPointer(directQuadPositionLoc_, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float),
+ reinterpret_cast(0));
+ glEnableVertexAttribArray(directQuadTexCoordLoc_);
+ glVertexAttribPointer(directQuadTexCoordLoc_, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float),
+ reinterpret_cast(2 * sizeof(float)));
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_2D, sharedGameTexture_);
+ glUniform1i(directGameSamplerLoc_, 0);
+ glActiveTexture(GL_TEXTURE1);
+ glBindTexture(GL_TEXTURE_2D, gameTexture_);
+ glUniform1i(directOverlaySamplerLoc_, 1);
+ glUniform2f(directContentScaleLoc_, quadContentScaleX_.load(), quadContentScaleY_.load());
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+ glBindBuffer(GL_ARRAY_BUFFER, 0);
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ glUseProgram(0);
+ } else if (gameTextureWidth_ > 0 && quadProgram_ != 0) {
+ glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glUseProgram(quadProgram_);
+ glBindBuffer(GL_ARRAY_BUFFER, quadVbo_);
+ glEnableVertexAttribArray(quadPositionLoc_);
+ glVertexAttribPointer(quadPositionLoc_, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float),
+ reinterpret_cast(0));
+ glEnableVertexAttribArray(quadTexCoordLoc_);
+ glVertexAttribPointer(quadTexCoordLoc_, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float),
+ reinterpret_cast(2 * sizeof(float)));
+ glActiveTexture(GL_TEXTURE0);
+ glBindTexture(GL_TEXTURE_2D, gameTexture_);
+ glUniform1i(quadSamplerLoc_, 0);
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+ glBindBuffer(GL_ARRAY_BUFFER, 0);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ glUseProgram(0);
+ } else {
+ // No game frame captured yet (e.g. libxrimmersive.so present but the app hasn't
+ // reached ImmersiveXrActivity's capture loop yet) — flat color instead of garbage.
+ glClearColor(0.05f, 0.05f, 0.08f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ }
+
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+
+ XrSwapchainImageReleaseInfo releaseInfo{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
+ xrReleaseSwapchainImage(swapchain_, &releaseInfo);
+}
+
+void XrImmersiveSession::submitQuadLayer(XrTime predictedDisplayTime, XrSpace space,
+ XrSwapchain swapchain, int32_t width, int32_t height,
+ bool sessionActive) {
+ if (!sessionActive) return;
+
+ XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD};
+ // Without this flag the compositor ignores the texture's alpha channel entirely and treats
+ // every pixel as fully opaque — the composited bitmap already has real alpha=0 in the
+ // pointer-margin band (Kotlin side clears it to transparent before drawing the game frame
+ // inset), but with no blend flag those pixels' RGB (0,0,0, i.e. black) still render at full
+ // opacity, which is exactly the black border the passthrough toggle was supposed to see
+ // through. This makes the quad respect its own alpha, so the passthrough layer behind it
+ // (when active) shows through those pixels instead.
+ quad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
+ quad.space = space;
+ quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
+ quad.subImage.swapchain = swapchain;
+ quad.subImage.imageRect.offset.x = 0;
+ quad.subImage.imageRect.offset.y = 0;
+ quad.subImage.imageRect.extent.width = width;
+ quad.subImage.imageRect.extent.height = height;
+ quad.subImage.imageArrayIndex = 0;
+ // Orbit around the player on BOTH axes instead of sliding on a flat plane: quadPosX_/quadPosY_
+ // are tangential (left/right, up/down) offsets and quadPosZ_ is always -distance (Kotlin
+ // never sends a raw Cartesian z), so distance = -quadPosZ_ is the orbit radius, yaw =
+ // atan2(quadPosX_, distance) is the angle swept left/right, and pitch = atan2(quadPosY_,
+ // distance) is the angle swept up/down. Position moves on the SPHERE of that radius (not a
+ // flat plane), and orientation rotates to keep facing the player at every point on it —
+ // moving the panel in any direction now feels like walking it around the player rather than
+ // sliding a flat sign past them, including vertically (an earlier version only did this for
+ // yaw/left-right, leaving up/down as a plain Cartesian translation that visibly turned away).
+ //
+ // Derivation: at yaw=pitch=0 (centered), identity orientation already faces the player (the
+ // pre-existing, known-working baseline) — so the quad's local +Z axis is its "faces the
+ // player" normal, and local +X/+Y are its right/up axes. Composing a yaw (about world Y) with
+ // a pitch (about the quad's own, already-yawed local X) and solving for "local +Z ends up
+ // pointing from the new position back to the player" gives position = distance *
+ // (sin(yaw)cos(pitch), sin(pitch), -cos(yaw)cos(pitch)) and orientation quaternion (using
+ // half-angles cy/sy for yaw, cx/sx for pitch, with the same yaw-sign convention as the
+ // yaw-only case): (cy*sx, -sy*cx, sy*sx, cy*cx) — reduces exactly to the old yaw-only
+ // quaternion (0, -sy, 0, cy) when pitch=0, confirming the two derivations agree.
+ const float distance = -quadPosZ_.load();
+ const float tangentialX = quadPosX_.load();
+ const float tangentialY = quadPosY_.load();
+ const float yaw = distance > 0.0001f ? std::atan2(tangentialX, distance) : 0.0f;
+ const float pitch = distance > 0.0001f ? std::atan2(tangentialY, distance) : 0.0f;
+ const float cy = std::cos(yaw * 0.5f);
+ const float sy = std::sin(yaw * 0.5f);
+ const float cx = std::cos(pitch * 0.5f);
+ const float sx = std::sin(pitch * 0.5f);
+ quad.pose = IdentityPose();
+ quad.pose.orientation.x = cy * sx;
+ quad.pose.orientation.y = -sy * cx;
+ quad.pose.orientation.z = sy * sx;
+ quad.pose.orientation.w = cy * cx;
+ quad.pose.position.x = distance * std::sin(yaw) * std::cos(pitch);
+ quad.pose.position.y = distance * std::sin(pitch);
+ quad.pose.position.z = -distance * std::cos(yaw) * std::cos(pitch);
+ quad.size.width = quadWidth_.load();
+ quad.size.height = quadHeight_.load();
+
+ // Unlike the quad, a passthrough layer isn't anchored to a location in the world — it
+ // replaces the whole environment/background — so per Meta's own samples it takes
+ // XR_NULL_HANDLE here, not a real reference space. Passing our local reference space (a
+ // valid, non-null XrSpace meant for spatially-anchored layers like the quad) is a plausible
+ // cause of the xrEndFrame validation failure (-1) seen on every frame once passthrough was
+ // toggled on, previously misdiagnosed as an unsupported blend mode.
+ XrCompositionLayerPassthroughFB passthroughLayer{XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_FB};
+ passthroughLayer.space = XR_NULL_HANDLE;
+ passthroughLayer.layerHandle = passthroughLayer_;
+
+ std::vector layers;
+ if (passthroughActive_ && passthroughLayer_ != XR_NULL_HANDLE) {
+ layers.push_back(reinterpret_cast(&passthroughLayer));
+ }
+ layers.push_back(reinterpret_cast(&quad));
+
+ // Passthrough shows the real world through gaps, so the "background" must be
+ // ADDITIVE/ALPHA_BLEND rather than OPAQUE while it's active, or it'll just look black.
+ XrFrameEndInfo endInfo{XR_TYPE_FRAME_END_INFO};
+ endInfo.displayTime = predictedDisplayTime;
+ endInfo.environmentBlendMode = passthroughActive_ ? XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND
+ : XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
+ endInfo.layerCount = static_cast(layers.size());
+ endInfo.layers = layers.data();
+ XrCheck(xrEndFrame(session_, &endInfo), "xrEndFrame");
+}
+
+void XrImmersiveSession::setQuadTransform(float x, float y, float z, float width, float height,
+ float contentScaleX, float contentScaleY) {
+ quadPosX_.store(x);
+ quadPosY_.store(y);
+ quadPosZ_.store(z);
+ quadWidth_.store(width);
+ quadHeight_.store(height);
+ quadContentScaleX_.store(contentScaleX);
+ quadContentScaleY_.store(contentScaleY);
+}
+
+void XrImmersiveSession::setPassthroughEnabled(bool enabled) {
+ passthroughRequested_.store(enabled);
+}
+
+void XrImmersiveSession::setupPassthrough() {
+ auto resolve = [this](const char *name, PFN_xrVoidFunction *out) {
+ xrGetInstanceProcAddr(instance_, name, out);
+ };
+ resolve("xrCreatePassthroughFB", reinterpret_cast(&xrCreatePassthroughFB_));
+ resolve("xrDestroyPassthroughFB", reinterpret_cast(&xrDestroyPassthroughFB_));
+ resolve("xrPassthroughStartFB", reinterpret_cast(&xrPassthroughStartFB_));
+ resolve("xrPassthroughPauseFB", reinterpret_cast(&xrPassthroughPauseFB_));
+ resolve("xrCreatePassthroughLayerFB",
+ reinterpret_cast(&xrCreatePassthroughLayerFB_));
+ resolve("xrDestroyPassthroughLayerFB",
+ reinterpret_cast(&xrDestroyPassthroughLayerFB_));
+ resolve("xrPassthroughLayerResumeFB",
+ reinterpret_cast(&xrPassthroughLayerResumeFB_));
+ resolve("xrPassthroughLayerPauseFB",
+ reinterpret_cast(&xrPassthroughLayerPauseFB_));
+
+ if (xrCreatePassthroughFB_ == nullptr || xrCreatePassthroughLayerFB_ == nullptr) {
+ LOGE("XR_FB_passthrough advertised but function pointers missing — disabling");
+ passthroughExtensionAvailable_ = false;
+ return;
+ }
+
+ XrPassthroughCreateInfoFB passthroughCreateInfo{XR_TYPE_PASSTHROUGH_CREATE_INFO_FB};
+ if (!XrCheck(xrCreatePassthroughFB_(session_, &passthroughCreateInfo, &passthrough_),
+ "xrCreatePassthroughFB")) {
+ passthroughExtensionAvailable_ = false;
+ return;
+ }
+
+ XrPassthroughLayerCreateInfoFB layerCreateInfo{XR_TYPE_PASSTHROUGH_LAYER_CREATE_INFO_FB};
+ layerCreateInfo.passthrough = passthrough_;
+ layerCreateInfo.purpose = XR_PASSTHROUGH_LAYER_PURPOSE_RECONSTRUCTION_FB;
+ if (!XrCheck(xrCreatePassthroughLayerFB_(session_, &layerCreateInfo, &passthroughLayer_),
+ "xrCreatePassthroughLayerFB")) {
+ passthroughExtensionAvailable_ = false;
+ return;
+ }
+
+ LOGI("Passthrough initialized (inactive until toggled on)");
+}
+
+void XrImmersiveSession::teardownPassthrough() {
+ if (passthroughLayer_ != XR_NULL_HANDLE && xrDestroyPassthroughLayerFB_ != nullptr) {
+ xrDestroyPassthroughLayerFB_(passthroughLayer_);
+ passthroughLayer_ = XR_NULL_HANDLE;
+ }
+ if (passthrough_ != XR_NULL_HANDLE && xrDestroyPassthroughFB_ != nullptr) {
+ xrDestroyPassthroughFB_(passthrough_);
+ passthrough_ = XR_NULL_HANDLE;
+ }
+}
+
+void XrImmersiveSession::applyPendingPassthroughState() {
+ if (!passthroughExtensionAvailable_) return;
+ if (!alphaBlendSupported_) {
+ passthroughRequested_.store(false);
+ return;
+ }
+ const bool requested = passthroughRequested_.load();
+ if (requested == passthroughActive_) return;
+
+ if (requested) {
+ // Only report "active" (and thus switch to ALPHA_BLEND + attach the passthrough layer in
+ // submitQuadLayer) if the runtime actually started passthrough — otherwise we'd flip to
+ // ALPHA_BLEND with no real passthrough image behind it, which just looks like a black
+ // screen (nothing bridges the gap where OPAQUE mode's implicit skybox used to be).
+ const bool started = XrCheck(xrPassthroughStartFB_(passthrough_), "xrPassthroughStartFB") &&
+ XrCheck(xrPassthroughLayerResumeFB_(passthroughLayer_), "xrPassthroughLayerResumeFB");
+ if (!started) {
+ LOGE("Passthrough failed to start — leaving environment opaque");
+ passthroughRequested_.store(false);
+ }
+ passthroughActive_ = started;
+ } else {
+ XrCheck(xrPassthroughLayerPauseFB_(passthroughLayer_), "xrPassthroughLayerPauseFB");
+ XrCheck(xrPassthroughPauseFB_(passthrough_), "xrPassthroughPauseFB");
+ passthroughActive_ = false;
+ }
+}
+
+void XrImmersiveSession::syncControllerInputs(XrTime predictedDisplayTime) {
+ XrActiveActionSet activeActionSet{actionSet_, XR_NULL_PATH};
+ XrActionsSyncInfo syncInfo{XR_TYPE_ACTIONS_SYNC_INFO};
+ syncInfo.countActiveActionSets = 1;
+ syncInfo.activeActionSets = &activeActionSet;
+ if (!XrCheck(xrSyncActions(session_, &syncInfo), "xrSyncActions")) return;
+
+ auto getBool = [this](XrAction action) {
+ XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO};
+ info.action = action;
+ XrActionStateBoolean state{XR_TYPE_ACTION_STATE_BOOLEAN};
+ xrGetActionStateBoolean(session_, &info, &state);
+ return state.isActive && state.currentState;
+ };
+ auto getFloat = [this](XrAction action) {
+ XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO};
+ info.action = action;
+ XrActionStateFloat state{XR_TYPE_ACTION_STATE_FLOAT};
+ xrGetActionStateFloat(session_, &info, &state);
+ return state.isActive ? state.currentState : 0.0f;
+ };
+ auto getVector2 = [this](XrAction action, float *x, float *y) {
+ XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO};
+ info.action = action;
+ XrActionStateVector2f state{XR_TYPE_ACTION_STATE_VECTOR2F};
+ xrGetActionStateVector2f(session_, &info, &state);
+ if (state.isActive) {
+ *x = state.currentState.x;
+ *y = state.currentState.y;
+ } else {
+ *x = *y = 0.0f;
+ }
+ };
+
+ InputSnapshot next;
+ next.buttons = 0;
+ if (getBool(buttonXAction_)) next.buttons |= (1u << BUTTON_X);
+ if (getBool(buttonYAction_)) next.buttons |= (1u << BUTTON_Y);
+ if (getBool(buttonAAction_)) next.buttons |= (1u << BUTTON_A);
+ if (getBool(buttonBAction_)) next.buttons |= (1u << BUTTON_B);
+ if (getFloat(squeezeLAction_) > 0.5f) next.buttons |= (1u << BUTTON_LB);
+ if (getFloat(squeezeRAction_) > 0.5f) next.buttons |= (1u << BUTTON_RB);
+
+ // Menu/Start button: a short press still forwards a normal Start press to the game (for
+ // the game's own pause/settings menu); holding it for 600ms instead opens the immersive
+ // quick menu. The raw OpenXR click can bounce (several false->true transitions within a
+ // single physical press), so lastMenuDebouncedPressed_ applies a short bounce-tolerance
+ // window before measuring hold duration — a millisecond-scale dropout mid-hold must not
+ // reset the 600ms timer.
+ const auto nowMenu = std::chrono::steady_clock::now();
+ const bool rawMenuPressed = getBool(menuLAction_);
+ if (rawMenuPressed) lastMenuRawTrueTime_ = nowMenu;
+ const bool debouncedMenuPressed = rawMenuPressed ||
+ (nowMenu - lastMenuRawTrueTime_) < std::chrono::milliseconds(50);
+ if (debouncedMenuPressed && !lastMenuDebouncedPressed_) {
+ menuHoldStartTime_ = nowMenu;
+ menuLongPressTriggered_ = false;
+ }
+ if (debouncedMenuPressed) {
+ if (!menuLongPressTriggered_ &&
+ (nowMenu - menuHoldStartTime_) >= std::chrono::milliseconds(600)) {
+ next.quickMenuClicked = true;
+ menuLongPressTriggered_ = true;
+ }
+ } else if (lastMenuDebouncedPressed_ && !menuLongPressTriggered_) {
+ // Released before reaching the long-press threshold: forward a clean momentary Start
+ // press to the game, same as a real controller's Menu button.
+ menuDebouncedActive_ = true;
+ menuPressStartTime_ = nowMenu;
+ }
+ lastMenuDebouncedPressed_ = debouncedMenuPressed;
+ next.menuButtonHeld = debouncedMenuPressed;
+ if (menuDebouncedActive_) {
+ if ((nowMenu - menuPressStartTime_) < std::chrono::milliseconds(150)) {
+ next.buttons |= (1u << BUTTON_START);
+ } else {
+ menuDebouncedActive_ = false;
+ }
+ }
+
+ const bool l3Pressed = getBool(thumbstickLClickAction_);
+ const bool r3Pressed = getBool(thumbstickRClickAction_);
+ if (l3Pressed) next.buttons |= (1u << BUTTON_L3);
+ if (r3Pressed) next.buttons |= (1u << BUTTON_R3);
+
+ next.triggerL = getFloat(triggerLAction_);
+ next.triggerR = getFloat(triggerRAction_);
+ getVector2(thumbstickLAction_, &next.leftX, &next.leftY);
+ getVector2(thumbstickRAction_, &next.rightX, &next.rightY);
+ // Confirmed by testing: this OpenXR runtime's thumbstick Y is inverted relative to what
+ // WinHandler's shared-memory XInput buffer expects (positive Y = down here, but the buffer
+ // wants positive Y = up, same as a real Xbox controller) — up/down were swapped in-game.
+ // Only the left stick was confirmed broken; negating both since they're read the same way
+ // and almost certainly share the same convention.
+ next.leftY = -next.leftY;
+ next.rightY = -next.rightY;
+ // No synthetic dpad: on a real Xbox controller the left stick and the d-pad are two
+ // separate physical controls, and Touch controllers have no physical d-pad at all. Faking
+ // one out of the stick's direction fired alongside the real analog values, which is why
+ // stick movement felt like the d-pad instead of a proper analog Xbox stick. leftX/leftY/
+ // rightX/rightY carry the real analog values; dpadUp/Down/Left/Right stay false.
+
+ // XR pointer-mode toggle: double-click of either thumbstick (L3 or R3) — checked
+ // independently rather than requiring both at once, since it's easier to double-click one
+ // stick with one hand than to coordinate a multi-button chord. Reserved for grabbing the
+ // quad's corner (resize) / top-bottom bar (reposition), and later a real VR game's own
+ // pointer needs.
+ const auto now = std::chrono::steady_clock::now();
+ auto detectDoubleClick = [&](bool pressed, bool &lastPressed,
+ std::chrono::steady_clock::time_point &lastClickTime,
+ int &clickCount) {
+ bool triggered = false;
+ if (pressed && !lastPressed) {
+ clickCount = (now - lastClickTime) < std::chrono::milliseconds(400) ? clickCount + 1 : 1;
+ lastClickTime = now;
+ if (clickCount >= 2) {
+ triggered = true;
+ clickCount = 0;
+ }
+ }
+ lastPressed = pressed;
+ return triggered;
+ };
+ const bool leftDoubleClick = detectDoubleClick(l3Pressed, lastL3Pressed_, lastL3ClickTime_, l3ClickCount_);
+ const bool rightDoubleClick = detectDoubleClick(r3Pressed, lastR3Pressed_, lastR3ClickTime_, r3ClickCount_);
+ next.pointerModeToggled = leftDoubleClick || rightDoubleClick;
+
+ // Aim-pose ray for each hand, in the same LOCAL space the quad transform lives in — lets
+ // Kotlin do a simple ray/plane intersection against the quad's known rectangle instead of
+ // needing any OpenXR types on that side.
+ auto rotateForward = [](const XrQuaternionf &q) {
+ // Standard quaternion-rotate-vector for v=(0,0,-1) (OpenXR's forward), expanded by hand
+ // since -1 in one component simplifies most of the cross-product terms away.
+ const float vx = 0.0f, vy = 0.0f, vz = -1.0f;
+ const float tx = 2.0f * (q.y * vz - q.z * vy);
+ const float ty = 2.0f * (q.z * vx - q.x * vz);
+ const float tz = 2.0f * (q.x * vy - q.y * vx);
+ const float rx = vx + q.w * tx + (q.y * tz - q.z * ty);
+ const float ry = vy + q.w * ty + (q.z * tx - q.x * tz);
+ const float rz = vz + q.w * tz + (q.x * ty - q.y * tx);
+ return std::array{rx, ry, rz};
+ };
+
+ XrSpaceLocation leftLoc{XR_TYPE_SPACE_LOCATION};
+ XrSpaceLocation rightLoc{XR_TYPE_SPACE_LOCATION};
+ constexpr XrSpaceLocationFlags kPoseValidBits =
+ XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
+ const bool haveLeft = aimSpaceLeft_ != XR_NULL_HANDLE &&
+ XrCheck(xrLocateSpace(aimSpaceLeft_, localSpace_, predictedDisplayTime, &leftLoc), "xrLocateSpace(left)") &&
+ (leftLoc.locationFlags & kPoseValidBits) == kPoseValidBits;
+ const bool haveRight = aimSpaceRight_ != XR_NULL_HANDLE &&
+ XrCheck(xrLocateSpace(aimSpaceRight_, localSpace_, predictedDisplayTime, &rightLoc), "xrLocateSpace(right)") &&
+ (rightLoc.locationFlags & kPoseValidBits) == kPoseValidBits;
+ next.handPosesValid = haveLeft && haveRight;
+ if (haveLeft) {
+ next.leftHandPosX = leftLoc.pose.position.x;
+ next.leftHandPosY = leftLoc.pose.position.y;
+ next.leftHandPosZ = leftLoc.pose.position.z;
+ const auto fwd = rotateForward(leftLoc.pose.orientation);
+ next.leftHandFwdX = fwd[0];
+ next.leftHandFwdY = fwd[1];
+ next.leftHandFwdZ = fwd[2];
+ }
+ if (haveRight) {
+ next.rightHandPosX = rightLoc.pose.position.x;
+ next.rightHandPosY = rightLoc.pose.position.y;
+ next.rightHandPosZ = rightLoc.pose.position.z;
+ const auto fwd = rotateForward(rightLoc.pose.orientation);
+ next.rightHandFwdX = fwd[0];
+ next.rightHandFwdY = fwd[1];
+ next.rightHandFwdZ = fwd[2];
+ }
+
+ // Rate-limited raw-value dump (~every 0.5s at 90Hz) — temporary, for diagnosing controller
+ // mapping reports; safe to remove once the analog stick mapping is confirmed solid.
+ if ((syncFrameCounter_++ % 45) == 0) {
+ LOGI("controller raw: leftX=%.2f leftY=%.2f rightX=%.2f rightY=%.2f buttons=0x%03x",
+ next.leftX, next.leftY, next.rightX, next.rightY, next.buttons);
+ }
+
+ std::lock_guard lock(snapshotMutex_);
+ // Preserve a quick-menu click that a concurrent pollSnapshot() hasn't consumed yet.
+ next.quickMenuClicked = next.quickMenuClicked || snapshot_.quickMenuClicked;
+ snapshot_ = next;
+}
+
+void XrImmersiveSession::teardown() {
+ teardownPassthrough();
+ if (gameTexture_ != 0) {
+ glDeleteTextures(1, &gameTexture_);
+ gameTexture_ = 0;
+ }
+ if (quadVbo_ != 0) {
+ glDeleteBuffers(1, &quadVbo_);
+ quadVbo_ = 0;
+ }
+ if (quadProgram_ != 0) {
+ glDeleteProgram(quadProgram_);
+ quadProgram_ = 0;
+ }
+ if (directQuadProgram_ != 0) {
+ glDeleteProgram(directQuadProgram_);
+ directQuadProgram_ = 0;
+ }
+ if (sharedGameTexture_ != 0) {
+ glDeleteTextures(1, &sharedGameTexture_);
+ sharedGameTexture_ = 0;
+ }
+ if (sharedGameImage_ != EGL_NO_IMAGE_KHR) {
+ eglDestroyImageKHR(eglDisplay_, sharedGameImage_);
+ sharedGameImage_ = EGL_NO_IMAGE_KHR;
+ }
+ {
+ std::lock_guard lock(sharedBufferMutex_);
+ if (pendingSharedBuffer_ != nullptr) {
+ AHardwareBuffer_release(pendingSharedBuffer_);
+ pendingSharedBuffer_ = nullptr;
+ }
+ }
+ if (framebuffer_ != 0) {
+ glDeleteFramebuffers(1, &framebuffer_);
+ framebuffer_ = 0;
+ }
+ if (swapchain_ != XR_NULL_HANDLE) {
+ xrDestroySwapchain(swapchain_);
+ swapchain_ = XR_NULL_HANDLE;
+ }
+ if (localSpace_ != XR_NULL_HANDLE) {
+ xrDestroySpace(localSpace_);
+ localSpace_ = XR_NULL_HANDLE;
+ }
+ if (aimSpaceLeft_ != XR_NULL_HANDLE) {
+ xrDestroySpace(aimSpaceLeft_);
+ aimSpaceLeft_ = XR_NULL_HANDLE;
+ }
+ if (aimSpaceRight_ != XR_NULL_HANDLE) {
+ xrDestroySpace(aimSpaceRight_);
+ aimSpaceRight_ = XR_NULL_HANDLE;
+ }
+ if (actionSet_ != XR_NULL_HANDLE) {
+ xrDestroyActionSet(actionSet_);
+ actionSet_ = XR_NULL_HANDLE;
+ }
+ if (session_ != XR_NULL_HANDLE) {
+ xrDestroySession(session_);
+ session_ = XR_NULL_HANDLE;
+ }
+ if (instance_ != XR_NULL_HANDLE) {
+ xrDestroyInstance(instance_);
+ instance_ = XR_NULL_HANDLE;
+ }
+ if (eglContext_ != EGL_NO_CONTEXT) {
+ eglMakeCurrent(eglDisplay_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ eglDestroyContext(eglDisplay_, eglContext_);
+ eglContext_ = EGL_NO_CONTEXT;
+ }
+ if (eglPbufferSurface_ != EGL_NO_SURFACE) {
+ eglDestroySurface(eglDisplay_, eglPbufferSurface_);
+ eglPbufferSurface_ = EGL_NO_SURFACE;
+ }
+}
+
+} // namespace xrimmersive
diff --git a/app/src/main/cpp/xrimmersive/xr_immersive.h b/app/src/main/cpp/xrimmersive/xr_immersive.h
new file mode 100644
index 0000000000..5d6a86b9b0
--- /dev/null
+++ b/app/src/main/cpp/xrimmersive/xr_immersive.h
@@ -0,0 +1,286 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define EGL_EGLEXT_PROTOTYPES
+#include
+#include
+#define GL_GLEXT_PROTOTYPES
+#include
+#include
+#include
+
+#define XR_USE_PLATFORM_ANDROID 1
+#define XR_USE_GRAPHICS_API_OPENGL_ES 1
+#include
+#include
+
+namespace xrimmersive {
+
+// Bit indices match XrGamepadBridge.kt / WinHandler.sendVirtualGamepadState's Xbox layout.
+enum ButtonBit {
+ BUTTON_A = 0,
+ BUTTON_B = 1,
+ BUTTON_X = 2,
+ BUTTON_Y = 3,
+ BUTTON_LB = 4,
+ BUTTON_RB = 5,
+ BUTTON_BACK = 6,
+ BUTTON_START = 7,
+ BUTTON_L3 = 8,
+ BUTTON_R3 = 9,
+};
+
+// Snapshot of one frame's controller state, written by the OpenXR thread and
+// polled from Kotlin. Deliberately plain-old-data so it can be copied under a
+// single mutex without touching any JNI/OpenXR types.
+struct InputSnapshot {
+ uint32_t buttons = 0;
+ // No d-pad: Touch controllers have no physical one, and faking it from the thumbstick
+ // direction fought with the real analog values (see syncControllerInputs()).
+ float leftX = 0.0f;
+ float leftY = 0.0f;
+ float rightX = 0.0f;
+ float rightY = 0.0f;
+ float triggerL = 0.0f;
+ float triggerR = 0.0f;
+ // Rising-edge only: holding the Touch controllers' Menu button for 600ms summons
+ // GameNative's own quick menu. A short press of the same button is still forwarded as a
+ // normal gamepad Start press (games use it for their own pause/settings menu).
+ bool quickMenuClicked = false;
+ // Debounced level (not edge) state of the same Menu button — true for the entire physical
+ // hold, from press to release, regardless of whether it's still building up to the 600ms
+ // threshold or has already fired quickMenuClicked. Lets Kotlin suppress menu-navigation
+ // reads (thumbstick-as-dpad, click) while the hand that's holding this button may still be
+ // resting on/near the thumbstick — see handleMenuNavigation's suppression kdoc.
+ bool menuButtonHeld = false;
+
+ // Rising-edge only: double-clicking either thumbstick (L3 or R3) toggles "XR pointer
+ // mode" — a laser-pointer-driven direct manipulation scheme for the quad (grab a corner to
+ // resize, grab the top/bottom bar to reposition), reserved with a real VR game's own
+ // pointer needs in mind later.
+ bool pointerModeToggled = false;
+ // Aim-pose ray for each hand (world position + normalized forward direction, in the same
+ // LOCAL reference space the quad transform is expressed in) — only meaningful pointer-mode.
+ bool handPosesValid = false;
+ float leftHandPosX = 0.0f, leftHandPosY = 0.0f, leftHandPosZ = 0.0f;
+ float leftHandFwdX = 0.0f, leftHandFwdY = 0.0f, leftHandFwdZ = -1.0f;
+ float rightHandPosX = 0.0f, rightHandPosY = 0.0f, rightHandPosZ = 0.0f;
+ float rightHandFwdX = 0.0f, rightHandFwdY = 0.0f, rightHandFwdZ = -1.0f;
+};
+
+// Owns the OpenXR instance/session and its dedicated frame-loop thread.
+class XrImmersiveSession {
+public:
+ bool initialize(JavaVM *vm, jobject activityRef);
+ void requestStop();
+ void join();
+
+ InputSnapshot pollSnapshot();
+
+ // Called from the JNI bridge with a freshly PixelCopy'd RGBA_8888 frame of the game's
+ // actual rendered output (see ImmersiveXrActivity's capture loop). Copies into a
+ // double-buffered CPU-side slot; the render thread uploads it to the GPU on its own.
+ void submitFrame(const uint8_t *rgbaPixels, int32_t width, int32_t height);
+
+ // Quad position (meters, in the LOCAL reference space; z is negative = in front of the
+ // player) and size (meters). x/y/z are reinterpreted as (tangential X offset, tangential Y
+ // offset, -radius) rather than plain Cartesian coordinates — see submitQuadLayer's kdoc — so
+ // the quad orbits the player on both axes and stays facing them as x and/or y move away from
+ // 0, instead of sliding on a flat plane and turning away. contentScaleX/Y are content-half-extent / quad-half-extent
+ // per axis (1.0 if the quad has no margin band beyond its content, e.g. the non-direct-render
+ // path where the composited bitmap already has the margin baked in) — only meaningful for
+ // the direct-render path's shader, which needs to know how much of the quad's UV space is
+ // real game texture vs. margin-only overlay (see kDirectQuadFragmentShader). Safe to call
+ // from any thread — read by the render thread at the top of each frame.
+ void setQuadTransform(float x, float y, float z, float width, float height,
+ float contentScaleX, float contentScaleY);
+
+ // Enables/disables the XR_FB_passthrough layer behind the quad. No-op (logged) if the
+ // runtime doesn't support the extension.
+ void setPassthroughEnabled(bool enabled);
+
+ // Direct-render path — see the member comment near sharedBufferMutex_. Safe to call from
+ // any thread; the actual EGLImage/texture import happens lazily on the render thread.
+ // Pass the SAME AHardwareBuffer every frame (GLRenderer writes into it repeatedly) — this
+ // only needs calling again if the buffer identity itself changes (e.g. a resize).
+ void setSharedGameBuffer(AHardwareBuffer *buffer);
+
+private:
+ void runLoop();
+ bool setupInstanceAndSession();
+ void teardown();
+ void pollXrEvents();
+ void renderFrame();
+ void syncControllerInputs(XrTime predictedDisplayTime);
+ void submitQuadLayer(XrTime predictedDisplayTime, XrSpace space, XrSwapchain swapchain,
+ int32_t width, int32_t height, bool sessionActive);
+ void ensureQuadGeometryAndShader();
+ void uploadPendingGameFrameLocked();
+ void setupPassthrough();
+ void teardownPassthrough();
+ void applyPendingPassthroughState();
+
+ JavaVM *vm_ = nullptr;
+ jobject activityRef_ = nullptr;
+
+ XrInstance instance_ = XR_NULL_HANDLE;
+ XrSystemId systemId_ = XR_NULL_SYSTEM_ID;
+ XrSession session_ = XR_NULL_HANDLE;
+ XrSpace localSpace_ = XR_NULL_HANDLE;
+ XrSwapchain swapchain_ = XR_NULL_HANDLE;
+ XrSessionState sessionState_ = XR_SESSION_STATE_UNKNOWN;
+ bool sessionRunning_ = false;
+
+ // Reverted back to 1280x720 — bumping this to 1920x1080 (2.25x the pixels) to reduce
+ // pointer-margin softening measurably made overall performance worse (confirmed by the user
+ // immediately after that change), and performance matters far more than this margin's
+ // sharpness. Revisit only alongside real perf headroom, not before.
+ static constexpr int32_t kSwapchainWidth = 1280;
+ static constexpr int32_t kSwapchainHeight = 720;
+
+ EGLDisplay eglDisplay_ = EGL_NO_DISPLAY;
+ EGLContext eglContext_ = EGL_NO_CONTEXT;
+ EGLSurface eglPbufferSurface_ = EGL_NO_SURFACE;
+ EGLConfig eglConfig_ = nullptr;
+ GLuint framebuffer_ = 0;
+ std::vector swapchainImages_;
+
+ // Textured quad used to draw the captured game frame into the swapchain image.
+ GLuint gameTexture_ = 0;
+ GLuint quadProgram_ = 0;
+ GLuint quadVbo_ = 0;
+ GLint quadPositionLoc_ = -1;
+ GLint quadTexCoordLoc_ = -1;
+ GLint quadSamplerLoc_ = -1;
+ int32_t gameTextureWidth_ = 0;
+ int32_t gameTextureHeight_ = 0;
+
+ // Direct-render path (setSharedGameBuffer): the game (GLRenderer, on its own thread/EGL
+ // context) writes straight into an AHardwareBuffer-backed texture instead of us
+ // screen-scraping its finished frame via PixelCopy. importSharedBufferIfNeeded() imports
+ // that same buffer a second time, into THIS thread's own context (AHardwareBuffer memory is
+ // shareable across contexts/processes; the texture/EGLImage wrapping it is not, so each side
+ // needs its own). When active, gameTexture_ above is repurposed to carry just the overlay
+ // layer (menu/HUD/Resume button — still CPU-composited, see uploadPendingGameFrameLocked)
+ // instead of the full composited frame, and the two are blended in directQuadProgram_.
+ std::mutex sharedBufferMutex_;
+ AHardwareBuffer *pendingSharedBuffer_ = nullptr;
+ bool sharedBufferChanged_ = false;
+ EGLImageKHR sharedGameImage_ = EGL_NO_IMAGE_KHR;
+ GLuint sharedGameTexture_ = 0;
+ bool hasSharedGameTexture_ = false;
+ GLuint directQuadProgram_ = 0;
+ GLint directQuadPositionLoc_ = -1;
+ GLint directQuadTexCoordLoc_ = -1;
+ GLint directGameSamplerLoc_ = -1;
+ GLint directOverlaySamplerLoc_ = -1;
+ GLint directContentScaleLoc_ = -1;
+
+ void importSharedBufferIfNeeded();
+
+ // Latest captured game frame, written by submitFrame() (any thread) and consumed by
+ // the render thread. Separate from snapshotMutex_ — controller input and video frames
+ // are unrelated and shouldn't contend on the same lock.
+ std::mutex frameMutex_;
+ std::vector pendingFramePixels_;
+ int32_t pendingFrameWidth_ = 0;
+ int32_t pendingFrameHeight_ = 0;
+ bool hasPendingFrame_ = false;
+
+ XrActionSet actionSet_ = XR_NULL_HANDLE;
+ XrAction buttonAAction_ = XR_NULL_HANDLE;
+ XrAction buttonBAction_ = XR_NULL_HANDLE;
+ XrAction buttonXAction_ = XR_NULL_HANDLE;
+ XrAction buttonYAction_ = XR_NULL_HANDLE;
+ XrAction squeezeLAction_ = XR_NULL_HANDLE;
+ XrAction squeezeRAction_ = XR_NULL_HANDLE;
+ XrAction triggerLAction_ = XR_NULL_HANDLE;
+ XrAction triggerRAction_ = XR_NULL_HANDLE;
+ XrAction thumbstickLAction_ = XR_NULL_HANDLE;
+ XrAction thumbstickRAction_ = XR_NULL_HANDLE;
+ XrAction thumbstickLClickAction_ = XR_NULL_HANDLE;
+ XrAction thumbstickRClickAction_ = XR_NULL_HANDLE;
+ XrAction menuLAction_ = XR_NULL_HANDLE;
+ uint64_t syncFrameCounter_ = 0;
+
+ // XR pointer-mode toggle: double-click of either thumbstick (L3 or R3) — see
+ // InputSnapshot::pointerModeToggled.
+ XrAction aimPoseLeftAction_ = XR_NULL_HANDLE;
+ XrAction aimPoseRightAction_ = XR_NULL_HANDLE;
+ XrSpace aimSpaceLeft_ = XR_NULL_HANDLE;
+ XrSpace aimSpaceRight_ = XR_NULL_HANDLE;
+ bool lastL3Pressed_ = false;
+ std::chrono::steady_clock::time_point lastL3ClickTime_;
+ int l3ClickCount_ = 0;
+ bool lastR3Pressed_ = false;
+ std::chrono::steady_clock::time_point lastR3ClickTime_;
+ int r3ClickCount_ = 0;
+
+ // Debounce state for the Menu/Start button. A short press still forwards a normal Start
+ // press to the game (menuDebouncedActive_/menuPressStartTime_); holding it for 600ms opens
+ // the immersive quick menu instead (see quickMenuClicked in the .cpp). The raw OpenXR click
+ // can bounce (several false->true transitions within a single physical press), so
+ // lastMenuDebouncedPressed_/lastMenuRawTrueTime_ apply a short bounce-tolerance window
+ // before measuring hold duration.
+ std::chrono::steady_clock::time_point lastMenuRawTrueTime_;
+ bool lastMenuDebouncedPressed_ = false;
+ std::chrono::steady_clock::time_point menuHoldStartTime_;
+ bool menuLongPressTriggered_ = false;
+ bool menuDebouncedActive_ = false;
+ std::chrono::steady_clock::time_point menuPressStartTime_;
+
+ std::thread thread_;
+ std::atomic stopRequested_{false};
+
+ std::mutex snapshotMutex_;
+ InputSnapshot snapshot_;
+
+ // Quad transform — defaults match the original hardcoded values (2m in front, 16:9,
+ // 1.6m wide). Atomics: written from Kotlin/JNI callers, read from the render thread.
+ std::atomic quadPosX_{0.0f};
+ std::atomic quadPosY_{0.0f};
+ std::atomic quadPosZ_{-2.0f};
+ std::atomic quadWidth_{1.6f};
+ std::atomic quadHeight_{0.9f};
+ std::atomic quadContentScaleX_{1.0f};
+ std::atomic quadContentScaleY_{1.0f};
+
+ // Passthrough (XR_FB_passthrough). All the passthrough* function pointers are resolved
+ // once in setupInstanceAndSession() and are null if the runtime doesn't support the
+ // extension, in which case setPassthroughEnabled() is a no-op.
+ bool passthroughExtensionAvailable_ = false;
+ // Whether this runtime/view-config actually lists ALPHA_BLEND in
+ // xrEnumerateEnvironmentBlendModes — requesting an unlisted blend mode in xrEndFrame is a
+ // validation error (observed: xrEndFrame failing with -1 on every frame once passthrough
+ // was toggled), so passthrough is refused entirely if this is false.
+ bool alphaBlendSupported_ = false;
+
+ // Perf/scheduling extensions — see setupInstanceAndSession()'s comment for why these were
+ // missing entirely before (this is the same architecture GameNativeXR's libxr.so uses, per
+ // standard OpenXR extensions, not anything proprietary).
+ bool perfSettingsExtensionAvailable_ = false;
+ bool threadSettingsExtensionAvailable_ = false;
+ bool refreshRateExtensionAvailable_ = false;
+
+ std::atomic passthroughRequested_{false};
+ bool passthroughActive_ = false;
+ XrPassthroughFB passthrough_ = XR_NULL_HANDLE;
+ XrPassthroughLayerFB passthroughLayer_ = XR_NULL_HANDLE;
+ PFN_xrCreatePassthroughFB xrCreatePassthroughFB_ = nullptr;
+ PFN_xrDestroyPassthroughFB xrDestroyPassthroughFB_ = nullptr;
+ PFN_xrPassthroughStartFB xrPassthroughStartFB_ = nullptr;
+ PFN_xrPassthroughPauseFB xrPassthroughPauseFB_ = nullptr;
+ PFN_xrCreatePassthroughLayerFB xrCreatePassthroughLayerFB_ = nullptr;
+ PFN_xrDestroyPassthroughLayerFB xrDestroyPassthroughLayerFB_ = nullptr;
+ PFN_xrPassthroughLayerResumeFB xrPassthroughLayerResumeFB_ = nullptr;
+ PFN_xrPassthroughLayerPauseFB xrPassthroughLayerPauseFB_ = nullptr;
+};
+
+} // namespace xrimmersive
diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt
index c8ef8d9757..c71878be15 100644
--- a/app/src/main/java/app/gamenative/MainActivity.kt
+++ b/app/src/main/java/app/gamenative/MainActivity.kt
@@ -80,6 +80,18 @@ class MainActivity : ComponentActivity() {
Build.MANUFACTURER.equals("Meta", true) ||
Build.MANUFACTURER.equals("Pico", true)
+ // Immersive/VR launch mode currently only targets Meta Quest — unlike isHeadset()
+ // above, this intentionally excludes Pico.
+ //
+ // Confirmed on a real Quest 3: `hasSystemFeature("android.hardware.vr.headtracking")`
+ // returns false for a plain 2D-panel app (that feature isn't declared outside an
+ // active VR/XR session), so — unlike an earlier version of this check — it must NOT
+ // be required here. Manufacturer/brand alone is enough, same as isHeadset() does.
+ fun isMetaQuest(context: Context): Boolean =
+ Build.MANUFACTURER.equals("Oculus", true) ||
+ Build.MANUFACTURER.equals("Meta", true) ||
+ Build.BRAND.equals("oculus", true)
+
// Store pending launch request to be processed after UI is ready
@Volatile
private var pendingLaunchRequest: IntentLaunchManager.LaunchRequest? = null
diff --git a/app/src/main/java/app/gamenative/data/DepotInfo.kt b/app/src/main/java/app/gamenative/data/DepotInfo.kt
index 0d5084ade0..4b38565871 100644
--- a/app/src/main/java/app/gamenative/data/DepotInfo.kt
+++ b/app/src/main/java/app/gamenative/data/DepotInfo.kt
@@ -27,8 +27,12 @@ data class DepotInfo(
val systemDefined: Boolean = false,
val steamDeck: Boolean = false,
) {
- /** Windows or OS-untagged (neither Linux nor macOS) */
+ /** Windows or OS-untagged (neither Linux, macOS nor Android) */
val isWindowsCompatible: Boolean
get() = osList.contains(OS.windows) ||
- (!osList.contains(OS.linux) && !osList.contains(OS.macos))
+ (!osList.contains(OS.linux) && !osList.contains(OS.macos) && !osList.contains(OS.android))
+
+ /** Explicitly tagged for Android (Steam Frame / Lepton depots) */
+ val isAndroidCompatible: Boolean
+ get() = osList.contains(OS.android)
}
diff --git a/app/src/main/java/app/gamenative/enums/OS.kt b/app/src/main/java/app/gamenative/enums/OS.kt
index c62c0203a0..f506986537 100644
--- a/app/src/main/java/app/gamenative/enums/OS.kt
+++ b/app/src/main/java/app/gamenative/enums/OS.kt
@@ -8,6 +8,7 @@ enum class OS(val code: Int) {
windows(0x01),
macos(0x02),
linux(0x04),
+ android(0x08),
;
companion object {
diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt
index a2ec39df10..eb900d5d85 100644
--- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt
+++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt
@@ -20,6 +20,7 @@ interface AndroidEvent : Event {
data class ShowLaunchingOverlay(val appName: String) : AndroidEvent
data object HideLaunchingOverlay : AndroidEvent
data class SetBootingSplashText(val text: String) : AndroidEvent
+ data object ClearBootingSplash : AndroidEvent
data class DownloadPausedDueToConnectivity(val appId: Int) : AndroidEvent
data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent
data class PostInstallSyncStatusChanged(val appId: Int, val isSyncing: Boolean) : AndroidEvent
diff --git a/app/src/main/java/app/gamenative/service/DownloadService.kt b/app/src/main/java/app/gamenative/service/DownloadService.kt
index 511f9e55f4..0d02635995 100644
--- a/app/src/main/java/app/gamenative/service/DownloadService.kt
+++ b/app/src/main/java/app/gamenative/service/DownloadService.kt
@@ -80,7 +80,7 @@ object DownloadService {
}
fun getSizeFromStoreDisplay (appId: Int, branch: String = "public"): String {
- val depots = SteamService.getDownloadableDepots(appId)
+ val depots = SteamService.getDownloadableDepots(appId, wantAndroid = SteamService.isAndroidPlatform(appId))
val installBytes = depots.values.sumOf { (it.manifests[branch] ?: it.manifests["public"])?.size ?: 0L }
return StorageUtils.formatBinarySize(installBytes)
}
diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt
index 62872563cc..538a1b18e3 100644
--- a/app/src/main/java/app/gamenative/service/SteamService.kt
+++ b/app/src/main/java/app/gamenative/service/SteamService.kt
@@ -50,6 +50,7 @@ import app.gamenative.enums.SaveLocation
import app.gamenative.enums.SyncResult
import app.gamenative.events.AndroidEvent
import app.gamenative.events.SteamEvent
+import app.gamenative.utils.AndroidGameLauncher
import app.gamenative.utils.CaseInsensitiveFileSystem
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.FileUtils
@@ -841,6 +842,7 @@ class SteamService : Service(), IChallengeUrlChanged {
licensedDepotIds: Set? = null,
hasSteamUnlockedBranch: Boolean = false,
dlcAppIdsWithSingleDepots: Set? = null,
+ wantAndroid: Boolean = false,
): Boolean {
if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty() && !hasSteamUnlockedBranch)
return false
@@ -850,9 +852,14 @@ class SteamService : Service(), IChallengeUrlChanged {
depot.sharedInstall
if (!hasContent)
return false
- // 2. Supported OS
- if (!depot.isWindowsCompatible)
+ // 2. Supported OS (Windows/untagged by default, or the Android depot when the
+ // user opted into the Android platform for this container)
+ if (wantAndroid) {
+ if (!depot.isAndroidCompatible)
+ return false
+ } else if (!depot.isWindowsCompatible) {
return false
+ }
// 3. 64-bit or indeterminate
// Arch selection: allow 64-bit and Unknown always.
// Allow 32-bit only when no 64-bit depot exists.
@@ -915,12 +922,14 @@ class SteamService : Service(), IChallengeUrlChanged {
preferredLanguage: String,
ownedDlc: Map?,
licensedDepotIds: Set?,
+ wantAndroid: Boolean = false,
): Collection {
val dlcAppIdsWithSingleDepots = getDlcAppIdsWithSingleDepot(depots)
return depots.values.filter { depot ->
filterForDownloadableDepots(depot, prefer64Bit = false, preferNonDeckWindows = false, preferredLanguage,
ownedDlc, licensedDepotIds,
- dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots
+ dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots,
+ wantAndroid = wantAndroid,
)
}
}
@@ -935,23 +944,25 @@ class SteamService : Service(), IChallengeUrlChanged {
ownedDlc: Map?,
licensedDepotIds: Set?,
hasSteamUnlockedBranch: Boolean = false,
+ wantAndroid: Boolean = false,
): Map {
val dlcAppIdsWithSingleDepots = getDlcAppIdsWithSingleDepot(depots)
val effectiveLanguage = SteamUtils.effectiveDepotLanguage(
- depots, preferredLanguage, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch,
+ depots, preferredLanguage, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch, wantAndroid,
)
- val eligible = eligibleDepots(depots, effectiveLanguage, ownedDlc, licensedDepotIds)
+ val eligible = eligibleDepots(depots, effectiveLanguage, ownedDlc, licensedDepotIds, wantAndroid)
val has64Bit = eligible.any { it.osArch == OSArch.Arch64 }
- val hasNonDeckWin = eligible.any { !it.steamDeck && it.isWindowsCompatible }
+ val hasNonDeckWin = eligible.any { !it.steamDeck && (if (wantAndroid) it.isAndroidCompatible else it.isWindowsCompatible) }
return depots.filter { (_, depot) ->
filterForDownloadableDepots(depot, has64Bit, hasNonDeckWin, effectiveLanguage,
ownedDlc, licensedDepotIds,
- dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots
+ dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots,
+ wantAndroid = wantAndroid,
)
}
}
- fun getMainAppDepots(appId: Int, containerLanguage: String): Map {
+ fun getMainAppDepots(appId: Int, containerLanguage: String, wantAndroid: Boolean = false): Map {
val appInfo = getAppInfoOf(appId) ?: return emptyMap()
val ownedDlc = runBlocking { getOwnedAppDlc(appId) }
val hasSteamUnlockedBranch = runBlocking { getSteamUnlockedBranches(appId).isNotEmpty() }
@@ -974,7 +985,7 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}
- val baseDepots = resolveDownloadableDepots(appInfo.depots, containerLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch)
+ val baseDepots = resolveDownloadableDepots(appInfo.depots, containerLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch, wantAndroid)
// Find in the depots of mainApp, that if any of the depotID is actually belongs to another steam_app entry
// override the dlcAppId to the corresponding app id
@@ -995,28 +1006,28 @@ class SteamService : Service(), IChallengeUrlChanged {
* Get downloadable depots for a given app (default language), including all DLCs
* @return Map of app ID to depot ID to depot info
*/
- fun getDownloadableDepots(appId: Int): Map {
+ fun getDownloadableDepots(appId: Int, wantAndroid: Boolean = false): Map {
val preferredLanguage = PrefManager.containerLanguage
- return getDownloadableDepots(appId, preferredLanguage)
+ return getDownloadableDepots(appId, preferredLanguage, wantAndroid)
}
/**
* Get downloadable depots for a given app (container language), including all DLCs
* @return Map of app ID to depot ID to depot info
*/
- fun getDownloadableDepots(appId: Int, preferredLanguage: String): Map {
+ fun getDownloadableDepots(appId: Int, preferredLanguage: String, wantAndroid: Boolean = false): Map {
val appInfo = getAppInfoOf(appId) ?: return emptyMap()
val ownedDlc = runBlocking { getOwnedAppDlc(appId) }
val hasSteamUnlockedBranch = runBlocking { getSteamUnlockedBranches(appId).isNotEmpty() }
val licensedDepots = getLicensedDepotIds(appId).orEmpty().toMutableSet()
- val map = getMainAppDepots(appId, preferredLanguage).toMutableMap()
+ val map = getMainAppDepots(appId, preferredLanguage, wantAndroid).toMutableMap()
// parent app's arch applies to DLC arch selection
val mainLanguage = SteamUtils.effectiveDepotLanguage(
- appInfo.depots, preferredLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch,
+ appInfo.depots, preferredLanguage, ownedDlc, licensedDepots, hasSteamUnlockedBranch, wantAndroid,
)
- val has64Bit = eligibleDepots(appInfo.depots, mainLanguage, ownedDlc, licensedDepots)
+ val has64Bit = eligibleDepots(appInfo.depots, mainLanguage, ownedDlc, licensedDepots, wantAndroid)
.any { it.osArch == OSArch.Arch64 }
val indirectDlcApps = getDownloadableDlcAppsOf(appId).orEmpty()
@@ -1025,15 +1036,16 @@ class SteamService : Service(), IChallengeUrlChanged {
val dlcLicensedDepots = getLicensedDepotIds(dlcApp.id)
// Resolve the DLC's own language too, so DLC that omits the container language installs.
val dlcLanguage = SteamUtils.effectiveDepotLanguage(
- dlcApp.depots, preferredLanguage, null, dlcLicensedDepots, hasSteamUnlockedBranch,
+ dlcApp.depots, preferredLanguage, null, dlcLicensedDepots, hasSteamUnlockedBranch, wantAndroid,
)
- val dlcEligible = eligibleDepots(dlcApp.depots, dlcLanguage, null, dlcLicensedDepots)
- val dlcHasNonDeckWin = dlcEligible.any { !it.steamDeck && it.isWindowsCompatible }
+ val dlcEligible = eligibleDepots(dlcApp.depots, dlcLanguage, null, dlcLicensedDepots, wantAndroid)
+ val dlcHasNonDeckWin = dlcEligible.any { !it.steamDeck && (if (wantAndroid) it.isAndroidCompatible else it.isWindowsCompatible) }
dlcApp.depots
.filter { (_, depot) ->
filterForDownloadableDepots(depot, has64Bit, dlcHasNonDeckWin, dlcLanguage,
null, dlcLicensedDepots, hasSteamUnlockedBranch,
- dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots
+ dlcAppIdsWithSingleDepots = dlcAppIdsWithSingleDepots,
+ wantAndroid = wantAndroid,
)
}
.forEach { (depotId, depot) ->
@@ -1407,6 +1419,13 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}
+ /** Whether the container for [appId] has the game's Android (Steam Frame / Lepton) depot selected. */
+ fun isAndroidPlatform(appId: Int): Boolean {
+ val context = instance?.applicationContext ?: return false
+ val container = ContainerManager(context).getContainerById("STEAM_${appId}")
+ return container?.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)
+ }
+
fun downloadApp(appId: Int, dlcAppIds: List, branch: String = "public", isUpdateOrVerify: Boolean): DownloadInfo? {
if (!checkWifiOrNotify()) return null
return getAppInfoOf(appId)?.let { appInfo ->
@@ -1416,17 +1435,17 @@ class SteamService : Service(), IChallengeUrlChanged {
} else {
PrefManager.containerLanguage
}
+ val wantAndroid = container?.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)
- Timber.tag("SteamService").d("downloadApp: downloading app $appId with language $containerLanguage, branch $branch")
-
- val depots = getDownloadableDepots(appId = appId, preferredLanguage = containerLanguage)
+ val depots = getDownloadableDepots(appId = appId, preferredLanguage = containerLanguage, wantAndroid = wantAndroid)
downloadApp(
appId = appId,
downloadableDepots = depots,
userSelectedDlcAppIds = dlcAppIds,
branch = branch,
containerLanguage = containerLanguage,
- isUpdateOrVerify = isUpdateOrVerify)
+ isUpdateOrVerify = isUpdateOrVerify,
+ wantAndroid = wantAndroid)
}
}
@@ -1726,6 +1745,7 @@ class SteamService : Service(), IChallengeUrlChanged {
branch: String,
containerLanguage: String,
isUpdateOrVerify: Boolean,
+ wantAndroid: Boolean = false,
): DownloadInfo? {
val appDirPath = getAppDirPath(appId)
@@ -1741,7 +1761,7 @@ class SteamService : Service(), IChallengeUrlChanged {
}
// Depots from Main game
- val mainDepots = getMainAppDepots(appId, containerLanguage)
+ val mainDepots = getMainAppDepots(appId, containerLanguage, wantAndroid)
var mainAppDepots = mainDepots.filter { (_, depot) ->
depot.dlcAppId == INVALID_APP_ID
} + mainDepots.filter { (_, depot) ->
@@ -2221,6 +2241,19 @@ class SteamService : Service(), IChallengeUrlChanged {
PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(appId, false))
}
}
+
+ // Android platform: the download itself doesn't put the game on the device —
+ // kick off the system install prompt right away instead of waiting for a
+ // manual Play click.
+ if (isAndroidPlatform(appId)) {
+ when (AndroidGameLauncher.installAndLaunch(svc.applicationContext, appId)) {
+ AndroidGameLauncher.Result.InstallStarted ->
+ SnackbarManager.show(svc.getString(R.string.android_game_install_started))
+ AndroidGameLauncher.Result.Failed ->
+ SnackbarManager.show(svc.getString(R.string.android_game_launch_failed))
+ AndroidGameLauncher.Result.Launched -> Unit
+ }
+ }
}
}
}
@@ -3564,11 +3597,15 @@ class SteamService : Service(), IChallengeUrlChanged {
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
- if (!hasActiveOperations()) {
+ // keepAlive is the same flag MainActivity's onStop/onDestroy already respect (see
+ // MainActivity.kt) — this callback is a separate Android lifecycle hook (fires when a
+ // TASK is torn down, not tied to any one Activity's own lifecycle) that was bypassing it
+ // entirely, force-stopping Steam out from under an immersive session in progress.
+ if (!hasActiveOperations() && !keepAlive) {
Timber.i("Task removed and no active work — stopping service")
stopSelf()
} else {
- Timber.i("Task removed but active work exists — keeping service alive")
+ Timber.i("Task removed but active work or keepAlive exists — keeping service alive")
}
}
diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt
index 1db8e407da..993d512b8b 100644
--- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt
+++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt
@@ -1614,12 +1614,34 @@ fun preLaunchApp(
// create container if it does not already exist
// TODO: combine somehow with container creation in HomeLibraryAppScreen
val containerManager = ContainerManager(context)
+
+ // If a container already exists and is set to the Android platform, short-circuit before
+ // any Wine-specific setup below. getOrCreateContainer() can extract a Wine container
+ // pattern when creating a brand-new container, which a native Android launch neither
+ // needs nor should be able to fail because of.
+ if (containerManager.hasContainer(appId)) {
+ val existing = containerManager.getContainerById(appId)
+ if (existing?.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)) {
+ setLoadingDialogVisible(false)
+ onSuccess(context, appId)
+ return@launch
+ }
+ }
+
val container = if (useTemporaryOverride) {
ContainerUtils.getOrCreateContainerWithOverride(context, appId)
} else {
ContainerUtils.getOrCreateContainer(context, appId)
}
+ // Native Android (Steam Frame / Lepton) build: no Wine/Proton container, no manifest
+ // components to resolve — skip straight to launch.
+ if (container.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)) {
+ setLoadingDialogVisible(false)
+ onSuccess(context, appId)
+ return@launch
+ }
+
// Clear session metadata on every launch to ensure fresh values
container.clearSessionMetadata()
diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
index 6b634ec6f0..3c1dafd2a5 100644
--- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
+++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
@@ -1,6 +1,7 @@
package app.gamenative.ui.component
import android.view.KeyEvent
+import timber.log.Timber
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
@@ -56,6 +57,7 @@ import androidx.compose.material.icons.filled.QueryStats
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.TouchApp
+import androidx.compose.material.icons.filled.ViewInAr
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
@@ -63,11 +65,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -76,6 +81,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@@ -98,6 +106,7 @@ import com.winlator.renderer.GLRenderer
import com.winlator.renderer.VulkanRenderer
import com.winlator.winhandler.ProcessInfo
import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import kotlin.math.roundToInt
object QuickMenuAction {
@@ -118,6 +127,7 @@ private object QuickMenuTab {
const val EFFECTS = 2
const val CONTROLLER = 3
const val TOOLS = 4
+ const val IMMERSIVE = 5
}
data class QuickMenuItem(
@@ -234,6 +244,35 @@ private fun matchesPerformanceHudPreset(
// fpsLimiterSteps / fpsLimiterCurrentIndex / fpsLimiterProgress /
// nextFpsLimiterValue / previousFpsLimiterValue live in FpsLimiterUtils.kt
+/**
+ * Lets ANY quick-menu row — no matter how deeply nested, in this file or any other — report its
+ * own gamepad interaction to the Meta Quest immersive activity, without threading a dedicated
+ * parameter through every intermediate composable in between (the previous approach: adding a
+ * new callback param to QuickMenuAdjustmentRow, then to its tab wrapper, then to QuickMenu itself,
+ * then to every call site — for every single row that needed it. A new row buried in some future
+ * tab would need the SAME threading redone by hand, easy to forget).
+ *
+ * Exists because normal Android/Compose gamepad input (real hardware, real KeyEvents) already
+ * works correctly for every existing row with zero extra code — this is ONLY needed because the
+ * immersive activity's Quest-Touch-controller input is synthetic and confirmed, repeatedly this
+ * session, to never reach Compose's own key dispatch or default click-on-focused-view behavior in
+ * that Activity. [QuickMenu] provides the real implementation (backed by state it forwards to
+ * ImmersiveXrActivity via registerAdjustmentControl/registerFocusedActivate); everywhere else —
+ * i.e. normal 2D panel mode — gets the no-op default below, so a row that reports itself here
+ * costs nothing and needs no `if (immersive)` branching of its own.
+ */
+class ImmersiveInputBypass(
+ // Reports (onDecrease, onIncrease) while a row is both focused and lock-toggled (A to lock,
+ // DPAD_LEFT/RIGHT to adjust, B or losing focus to unlock) — for slider-style rows.
+ val reportAdjustment: (Pair<() -> Unit, () -> Unit>?) -> Unit,
+ // Reports a row's own click/select action while it's focused — for plain radio/toggle rows
+ // that only need a single BUTTON_A/DPAD_CENTER press to activate.
+ val reportActivate: ((() -> Unit)?) -> Unit,
+)
+val LocalImmersiveInputBypass = staticCompositionLocalOf {
+ ImmersiveInputBypass(reportAdjustment = {}, reportActivate = {})
+}
+
@Composable
fun QuickMenu(
isVisible: Boolean,
@@ -268,9 +307,71 @@ fun QuickMenu(
onLsfgMultiplierChanged: (Int) -> Unit = {},
onLsfgFlowScaleChanged: (Float) -> Unit = {},
onLsfgPerformanceModeChanged: (Boolean) -> Unit = {},
+ // Immersive tab — only shown when non-null (i.e. hosted by ImmersiveXrActivity)
+ immersiveControls: app.gamenative.ui.screen.xr.ImmersiveControls? = null,
onAnimationComplete: (Boolean) -> Unit = {},
+ // Hands the live FocusManager up to the caller — see its call site's kdoc for why:
+ // dispatching synthetic dpad KeyEvents through Activity.dispatchKeyEvent() turned out to
+ // never reach Compose's key/focus dispatch at all in the immersive activity (confirmed via
+ // logging: dispatchKeyEvent() gets called and returns false, but nothing here ever sees the
+ // event — some other Android View still holds real view-level focus). Calling
+ // FocusManager.moveFocus() directly bypasses that whole broken path.
+ registerFocusManager: ((androidx.compose.ui.focus.FocusManager) -> Unit)? = null,
+ // Same bypass, for the tab rail specifically: LB/RB tab-cycling used to go through
+ // dispatchMenuKeyEvent (KEYCODE_BUTTON_L1/R1) into the SAME broken KeyEvent path — this hands
+ // up a direct "cycle forward/backward through the tabs actually shown right now" action
+ // instead, called straight from ImmersiveXrActivity's LB/RB handling.
+ registerCycleTab: ((Boolean) -> Unit) -> Unit = {},
+ // Same bypass again, for a locked QuickMenuAdjustmentRow's left/right slider drag: arrow-key
+ // movement already skips Compose's key dispatch entirely for this Activity (moveMenuFocus
+ // calls FocusManager.moveFocus() directly, see registerFocusManager's kdoc above) — which
+ // means a locked slider's own onPreviewKeyEvent (listening for DPAD_LEFT/RIGHT) never sees
+ // those presses either, since moveFocus fires first and moves focus away mid-adjustment
+ // instead. Registers a live getter for "is some row currently locked, and if so its
+ // decrease/increase actions" so ImmersiveXrActivity can drive it directly, the same way it
+ // drives tab cycling and focus movement.
+ registerAdjustmentControl: (() -> Pair<() -> Unit, () -> Unit>?) -> Unit = {},
+ // Same bypass again, for LEFT specifically: FocusManager.moveFocus(Left) reaching the tab
+ // rail from tab content turned out to depend on the focused row's vertical position lining
+ // up with a rail icon in Compose's spatial search heuristic — it worked from some rows and
+ // not others, confirmed by comparing logs from a session where it worked against one where it
+ // didn't. A direct requestFocus() on the CURRENTLY SELECTED tab's own rail button is
+ // reliable regardless of where in the content focus currently is, same as registerCycleTab.
+ registerFocusTabRail: (() -> Unit) -> Unit = {},
+ // Same bypass again, for a plain radio/selectable row that only reacts to BUTTON_A/DPAD_CENTER
+ // (e.g. ScreenEffectsPanel's scaling-mode rows): relying on Android's default "click whatever
+ // holds real view focus" behavior for that key turned out to be exactly as unreliable here as
+ // it was for the tab rail and adjustment-row lock — confirmed via logs showing scaling-mode
+ // selection simply not registering in the immersive activity despite Compose showing the row
+ // as focused. Registers a live getter for "is some row focused right now that just wants a
+ // plain activate on A, and if so its action" so ImmersiveXrActivity can invoke it directly.
+ registerFocusedActivate: (() -> (() -> Unit)?) -> Unit = {},
+ // Receives the Menu/Start button's physical-hold state, pushed live from
+ // ImmersiveXrActivity's native polling. While true, this Composable's own onPreviewKeyEvent
+ // consumes any key event it sees — Horizon OS's own gamepad input dispatch generates real
+ // Android KeyEvents (DPAD_CENTER/BUTTON_A/BUTTON_START) while that button is physically held,
+ // a completely separate path from the immersive activity's OpenXR-polling-driven bypasses,
+ // which has no visibility into or control over it.
+ registerStartHeld: ((Boolean) -> Unit) -> Unit = {},
modifier: Modifier = Modifier,
) {
+ val focusManager = LocalFocusManager.current
+ LaunchedEffect(registerFocusManager) {
+ registerFocusManager?.invoke(focusManager)
+ }
+ val focusTabRailScope = rememberCoroutineScope()
+ var activeAdjustment by remember { mutableStateOf Unit, () -> Unit>?>(null) }
+ var activeRadioActivate by remember { mutableStateOf<(() -> Unit)?>(null) }
+ LaunchedEffect(Unit) {
+ registerFocusedActivate { activeRadioActivate }
+ }
+ var startHeld by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ registerStartHeld { startHeld = it }
+ }
+ LaunchedEffect(Unit) {
+ registerAdjustmentControl { activeAdjustment }
+ }
val exitGameItem = QuickMenuItem(
id = QuickMenuAction.EXIT_GAME,
icon = Icons.AutoMirrored.Filled.ExitToApp,
@@ -341,9 +442,11 @@ fun QuickMenu(
var selectedTab by rememberSaveable {
mutableIntStateOf(
- if (PrefManager.quickMenuLastTab == QuickMenuTab.LSFG && !isLsfgAvailable)
- QuickMenuTab.HUD
- else PrefManager.quickMenuLastTab
+ when {
+ PrefManager.quickMenuLastTab == QuickMenuTab.LSFG && !isLsfgAvailable -> QuickMenuTab.HUD
+ PrefManager.quickMenuLastTab == QuickMenuTab.IMMERSIVE && immersiveControls == null -> QuickMenuTab.HUD
+ else -> PrefManager.quickMenuLastTab
+ }
)
}
val selectedTabLabelResId = when (selectedTab) {
@@ -351,6 +454,7 @@ fun QuickMenu(
QuickMenuTab.LSFG -> R.string.lsfg_tab_title
QuickMenuTab.EFFECTS -> R.string.screen_effects
QuickMenuTab.TOOLS -> R.string.task_manager
+ QuickMenuTab.IMMERSIVE -> R.string.quick_menu_tab_immersive
else -> R.string.quick_menu_tab_controller
}
@@ -368,6 +472,9 @@ fun QuickMenu(
val controllerItemFocusRequester = remember { FocusRequester() }
val toolsItemFocusRequester = remember { FocusRequester() }
val lsfgItemFocusRequester = remember { FocusRequester() }
+ val immersiveScrollState = rememberScrollState()
+ val immersiveTabFocusRequester = remember { FocusRequester() }
+ val immersiveItemFocusRequester = remember { FocusRequester() }
val visibleState = remember { MutableTransitionState(false) }
visibleState.targetState = isVisible
@@ -382,7 +489,120 @@ fun QuickMenu(
onDismiss()
}
- Box(modifier = modifier.fillMaxSize()) {
+ // Only the tabs actually shown in the rail, in on-screen order — mirrors the conditions each
+ // QuickMenuTabButton below is gated on (isLsfgAvailable, a renderer being available, etc).
+ val availableTabs = remember(isLsfgAvailable, renderer, glRenderer, immersiveControls) {
+ buildList {
+ add(QuickMenuTab.HUD)
+ if (isLsfgAvailable) add(QuickMenuTab.LSFG)
+ if (renderer != null || glRenderer != null) add(QuickMenuTab.EFFECTS)
+ add(QuickMenuTab.CONTROLLER)
+ add(QuickMenuTab.TOOLS)
+ if (immersiveControls != null) add(QuickMenuTab.IMMERSIVE)
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ registerFocusTabRail {
+ val requester = when (selectedTab) {
+ QuickMenuTab.HUD -> hudTabFocusRequester
+ QuickMenuTab.LSFG -> lsfgTabFocusRequester
+ QuickMenuTab.EFFECTS -> effectsTabFocusRequester
+ QuickMenuTab.CONTROLLER -> controllerTabFocusRequester
+ QuickMenuTab.TOOLS -> toolsTabFocusRequester
+ QuickMenuTab.IMMERSIVE -> immersiveTabFocusRequester
+ else -> null
+ }
+ if (requester == null) return@registerFocusTabRail
+ // A single attempt genuinely failed in practice — "FocusRequester is not
+ // initialized" thrown for the rail button's own requester, confirmed via logging —
+ // meaning the rail wasn't done composing/attaching yet at that exact instant. The
+ // OTHER requestFocus call site in this file (content-item focus on tab switch)
+ // already retries 3x for the identical reason; this one previously didn't, so a
+ // single mistimed LEFT press silently did nothing instead of retrying like that one.
+ focusTabRailScope.launch {
+ repeat(3) { attempt ->
+ try {
+ requester.requestFocus()
+ Timber.i("QuickMenu: requestFocusTabRail succeeded for tab=%s on attempt=%d", selectedTab, attempt)
+ return@launch
+ } catch (e: IllegalStateException) {
+ Timber.w(e, "QuickMenu: requestFocusTabRail failed for tab=%s on attempt=%d", selectedTab, attempt)
+ delay(80)
+ }
+ }
+ Timber.w("QuickMenu: requestFocusTabRail never succeeded for tab=%s after 3 attempts", selectedTab)
+ }
+ }
+ }
+
+ LaunchedEffect(availableTabs) {
+ registerCycleTab { forward ->
+ val currentIndex = availableTabs.indexOf(selectedTab).takeIf { it >= 0 } ?: 0
+ val nextTab = if (forward) {
+ availableTabs[(currentIndex + 1) % availableTabs.size]
+ } else {
+ availableTabs[(currentIndex - 1 + availableTabs.size) % availableTabs.size]
+ }
+ selectedTab = nextTab
+ PrefManager.quickMenuLastTab = nextTab
+ }
+ }
+
+ CompositionLocalProvider(
+ LocalImmersiveInputBypass provides ImmersiveInputBypass(
+ reportAdjustment = { activeAdjustment = it },
+ reportActivate = { activeRadioActivate = it },
+ ),
+ ) {
+ Box(
+ modifier = modifier.fillMaxSize()
+ // Directly cycling tabs on the shoulder buttons, instead of relying on Compose's
+ // directional (dpad) focus search to cross from the content pane into the separate
+ // tab-rail column, sidesteps a real reported problem: users couldn't reach the rail
+ // ("les categories") via dpad-left at all once focus was inside a tab's content —
+ // same LB/RB pattern as a real console dashboard's tab switching, and it works
+ // regardless of where focus currently is or how the two panes are laid out.
+ .onPreviewKeyEvent { keyEvent ->
+ // Real KeyEvent.KEYCODE_BUTTON_START/DPAD_CENTER/BUTTON_A events reach here
+ // through Android's own gamepad input dispatch while the Menu/Start button is
+ // physically held — a completely separate path from the immersive activity's
+ // native OpenXR polling/bypass mechanism, which has no visibility into or control
+ // over it. Left unconsumed, Android's key-fallback mechanism re-dispatches
+ // BUTTON_START as KEYCODE_DPAD_CENTER — the "click the focused element" behavior
+ // reported as "Start acts like the A button" (toggling the HUD/FPS limiter,
+ // unlocking a locked adjustment row, etc., purely from holding Start).
+ // registerStartHeld's level state covers this for every affected key code, not
+ // just BUTTON_START — consuming everything while it's true, on every action (not
+ // just ACTION_DOWN), blocks the fallback from ever firing while the menu is
+ // visible; Start's actual open/close behavior is handled entirely separately, by
+ // the long-press detector in ImmersiveXrActivity (see toggleQuickMenu).
+ if (isVisible && startHeld) {
+ return@onPreviewKeyEvent true
+ }
+ if (keyEvent.nativeKeyEvent.action != KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent false
+ // Fires for ANY key event that reaches this Composable, from wherever focus
+ // currently is, bubbling up through every ancestor on its way here (onPreviewKeyEvent
+ // semantics) — if a dispatched dpad/DPAD_CENTER event never shows up in this log at
+ // all, the event isn't reaching Compose's key/focus dispatch in the first place;
+ // if it DOES show up but focus never visibly moves, the problem is downstream of
+ // here (directional focus search itself, or nothing was focused to move from).
+ Timber.i("QuickMenu: onPreviewKeyEvent keyCode=%d selectedTab=%d", keyEvent.nativeKeyEvent.keyCode, selectedTab)
+ val currentIndex = availableTabs.indexOf(selectedTab).takeIf { it >= 0 } ?: 0
+ val nextTab = when (keyEvent.nativeKeyEvent.keyCode) {
+ KeyEvent.KEYCODE_BUTTON_L1 -> availableTabs[(currentIndex - 1 + availableTabs.size) % availableTabs.size]
+ KeyEvent.KEYCODE_BUTTON_R1 -> availableTabs[(currentIndex + 1) % availableTabs.size]
+ else -> null
+ }
+ if (nextTab != null) {
+ selectedTab = nextTab
+ PrefManager.quickMenuLastTab = nextTab
+ true
+ } else {
+ false
+ }
+ },
+ ) {
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(animationSpec = tween(200)),
@@ -392,11 +612,13 @@ fun QuickMenu(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0f))
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
- onClick = onDismiss,
- ),
+ // Not .clickable() on purpose: that implicitly adds a focus target the size
+ // of the whole screen, sitting right behind every other focusable item in
+ // the menu — a prime candidate for Compose's directional (dpad) focus search
+ // to land on by mistake, which would silently close the whole menu on the
+ // next DPAD_CENTER. pointerInput+detectTapGestures dismisses on tap without
+ // registering any focus node at all.
+ .pointerInput(Unit) { detectTapGestures { onDismiss() } },
)
}
@@ -529,6 +751,20 @@ fun QuickMenu(
modifier = Modifier.width(56.dp),
focusRequester = toolsTabFocusRequester,
)
+ if (immersiveControls != null) {
+ QuickMenuTabButton(
+ icon = Icons.Default.ViewInAr,
+ contentDescriptionResId = R.string.quick_menu_tab_immersive,
+ selected = selectedTab == QuickMenuTab.IMMERSIVE,
+ accentColor = PluviaTheme.colors.accentPurple,
+ onSelected = {
+ selectedTab = QuickMenuTab.IMMERSIVE
+ PrefManager.quickMenuLastTab = selectedTab
+ },
+ modifier = Modifier.width(56.dp),
+ focusRequester = immersiveTabFocusRequester,
+ )
+ }
}
Box(
@@ -655,6 +891,17 @@ fun QuickMenu(
)
}
+ QuickMenuTab.IMMERSIVE -> {
+ if (immersiveControls != null) {
+ ImmersiveQuickMenuTab(
+ controls = immersiveControls,
+ scrollState = immersiveScrollState,
+ focusRequester = immersiveItemFocusRequester,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
else -> {
Column(
modifier = Modifier
@@ -700,27 +947,32 @@ fun QuickMenu(
}
}
}
+ }
LaunchedEffect(isVisible, selectedTab) {
onToolsVisibilityChanged(isVisible && selectedTab == QuickMenuTab.TOOLS)
}
- LaunchedEffect(isVisible) {
+ LaunchedEffect(isVisible, selectedTab) {
if (isVisible) {
- repeat(3) {
+ repeat(3) { attempt ->
try {
when (selectedTab) {
QuickMenuTab.HUD -> hudItemFocusRequester.requestFocus()
QuickMenuTab.LSFG -> lsfgItemFocusRequester.requestFocus()
QuickMenuTab.EFFECTS -> effectsItemFocusRequester.requestFocus()
QuickMenuTab.TOOLS -> toolsItemFocusRequester.requestFocus()
+ QuickMenuTab.IMMERSIVE -> immersiveItemFocusRequester.requestFocus()
else -> controllerItemFocusRequester.requestFocus()
}
+ Timber.i("QuickMenu: requestFocus succeeded for tab=%d on attempt=%d", selectedTab, attempt)
return@LaunchedEffect
- } catch (_: Exception) {
+ } catch (t: Exception) {
+ Timber.w(t, "QuickMenu: requestFocus threw for tab=%d on attempt=%d", selectedTab, attempt)
delay(80)
}
}
+ Timber.w("QuickMenu: requestFocus never succeeded for tab=%d after 3 attempts", selectedTab)
}
}
}
@@ -855,7 +1107,9 @@ private fun PerformanceHudQuickMenuTab(
)
Row(
- modifier = Modifier.padding(horizontal = 8.dp),
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .focusGroup(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PerformanceHudPreset.values().forEach { preset ->
@@ -889,7 +1143,9 @@ private fun PerformanceHudQuickMenuTab(
)
Row(
- modifier = Modifier.padding(horizontal = 8.dp),
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .focusGroup(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
@@ -1145,7 +1401,9 @@ private fun LsfgQuickMenuTab(
title = stringResource(R.string.lsfg_multiplier),
)
Row(
- modifier = Modifier.padding(horizontal = 8.dp),
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .focusGroup(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(0, 2, 3, 4).forEach { value ->
@@ -1202,6 +1460,111 @@ private fun LsfgQuickMenuTab(
}
}
+@Composable
+private fun ImmersiveQuickMenuTab(
+ controls: app.gamenative.ui.screen.xr.ImmersiveControls,
+ scrollState: ScrollState,
+ focusRequester: FocusRequester? = null,
+ modifier: Modifier = Modifier,
+) {
+ val accentColor = PluviaTheme.colors.accentPurple
+
+ Column(
+ modifier = modifier
+ .verticalScroll(scrollState)
+ .focusGroup(),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // ── Passthrough ────────────────────────────────────────────────
+ QuickMenuSectionHeader(
+ title = stringResource(R.string.immersive_passthrough),
+ subtitle = stringResource(R.string.immersive_passthrough_desc),
+ )
+ QuickMenuToggleRow(
+ title = stringResource(R.string.immersive_passthrough_toggle),
+ enabled = controls.passthroughEnabled,
+ onToggle = { controls.onPassthroughToggle(!controls.passthroughEnabled) },
+ accentColor = accentColor,
+ focusRequester = focusRequester,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // ── Resize/move handles ───────────────────────────────────────────
+ // Position/size sliders removed at the user's explicit request — the trigger-driven
+ // grab handles (drawn just inside the content edges while this toggle is on) are now the
+ // ONLY way to move/resize, not an alternative alongside a slider-based one.
+ QuickMenuSectionHeader(
+ title = stringResource(R.string.immersive_resize_mode_title),
+ subtitle = stringResource(R.string.immersive_resize_mode_desc),
+ )
+ QuickMenuToggleRow(
+ title = stringResource(R.string.immersive_resize_mode_toggle),
+ enabled = controls.resizeModeEnabled,
+ onToggle = { controls.onResizeModeToggle(!controls.resizeModeEnabled) },
+ accentColor = accentColor,
+ )
+
+ if (controls.directRenderBlockedByEffects != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ QuickMenuSectionHeader(
+ title = stringResource(R.string.immersive_direct_render_title),
+ subtitle = stringResource(
+ if (controls.directRenderBlockedByEffects) {
+ R.string.immersive_reset_effects_status_blocked
+ } else {
+ R.string.immersive_reset_effects_status_ok
+ },
+ ),
+ )
+ val resetInteractionSource = remember { MutableInteractionSource() }
+ val resetIsFocused by resetInteractionSource.collectIsFocusedAsState()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(
+ if (resetIsFocused) {
+ accentColor.copy(alpha = 0.16f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.18f)
+ },
+ )
+ .then(
+ if (resetIsFocused) {
+ Modifier.border(width = 2.dp, color = accentColor.copy(alpha = 0.7f), shape = RoundedCornerShape(14.dp))
+ } else {
+ Modifier
+ }
+ )
+ .selectable(
+ selected = false,
+ interactionSource = resetInteractionSource,
+ indication = null,
+ onClick = controls.onResetScreenEffects,
+ )
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.immersive_reset_effects_button),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = stringResource(R.string.immersive_reset_effects_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+}
+
@Composable
private fun QuickMenuSectionHeader(
title: String,
@@ -1253,8 +1616,7 @@ private fun QuickMenuCloseButton(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
- )
- .focusable(interactionSource = interactionSource),
+ ),
contentAlignment = Alignment.Center,
) {
Icon(
@@ -1283,6 +1645,14 @@ private fun QuickMenuTabButton(
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val shape = RoundedCornerShape(14.dp)
+ // Same default-click unreliability as ScreenEffectRadioRow/QuickMenuChoiceChip — see
+ // LocalImmersiveInputBypass's kdoc. Without this, moving focus onto a rail button via LEFT
+ // + UP/DOWN and pressing A didn't reliably select the tab; LB/RB (registerCycleTab, which
+ // calls onSelected directly) was the only reliable way in.
+ val inputBypass = LocalImmersiveInputBypass.current
+ LaunchedEffect(isFocused) {
+ inputBypass.reportActivate(if (isFocused) onSelected else null)
+ }
Box(
modifier = modifier
@@ -1303,18 +1673,19 @@ private fun QuickMenuTabButton(
Modifier
}
)
- .onFocusChanged {
- if (it.isFocused && !selected) {
- onSelected()
- }
- }
+ // Selecting on focus alone (not requiring a click/DPAD_CENTER) was a real hazard once
+ // directional focus movement actually started working (see registerFocusManager):
+ // moving focus onto a rail button recomposes the whole tab content area, which can
+ // reshuffle what's focusable and trigger another focus change, another selection,
+ // another recompose — the tab switch that froze the menu. Selecting only on an
+ // explicit click (still fires from LB/RB now, see registerTabSelector) removes that
+ // whole class of cascade.
.selectable(
selected = selected,
interactionSource = interactionSource,
indication = null,
onClick = onSelected,
- )
- .focusable(interactionSource = interactionSource),
+ ),
contentAlignment = Alignment.Center,
) {
Icon(
@@ -1382,8 +1753,7 @@ private fun QuickMenuRailActionButton(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
- )
- .focusable(interactionSource = interactionSource),
+ ),
contentAlignment = Alignment.Center,
) {
Icon(
@@ -1407,6 +1777,10 @@ private fun QuickMenuChoiceChip(
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val shape = RoundedCornerShape(12.dp)
+ val inputBypass = LocalImmersiveInputBypass.current
+ LaunchedEffect(isFocused) {
+ inputBypass.reportActivate(if (isFocused) onClick else null)
+ }
Box(
modifier = modifier
@@ -1447,7 +1821,6 @@ private fun QuickMenuChoiceChip(
indication = null,
onClick = onClick,
)
- .focusable(interactionSource = interactionSource)
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
@@ -1476,6 +1849,16 @@ private fun QuickMenuAdjustmentRow(
val isFocused by interactionSource.collectIsFocusedAsState()
val shape = RoundedCornerShape(14.dp)
var isAdjustmentLocked by remember { mutableStateOf(false) }
+ // Reports (onDecrease, onIncrease) up while this row is both focused and lock-toggled, null
+ // otherwise — see LocalImmersiveInputBypass's kdoc. Keyed only on isFocused/isAdjustmentLocked,
+ // not onDecrease/onIncrease: those two are fresh lambda instances every recomposition (inline
+ // closures over the caller's own state), which would restart this effect on every unrelated
+ // recompose — harmless but wasteful. The closures stored here stay valid regardless of when
+ // they were captured, since they read their captured state fresh at call time, not creation.
+ val inputBypass = LocalImmersiveInputBypass.current
+ LaunchedEffect(isFocused, isAdjustmentLocked) {
+ inputBypass.reportAdjustment(if (isFocused && isAdjustmentLocked) (onDecrease to onIncrease) else null)
+ }
Column(
modifier = modifier
@@ -1518,6 +1901,14 @@ private fun QuickMenuAdjustmentRow(
)
.onFocusChanged {
if (!it.isFocused) {
+ // Only logged when this row was actually locked — an unrelated row losing
+ // focus during normal navigation is expected and not interesting; a LOCKED
+ // row losing focus (forcing the lock off) is exactly the event that needed
+ // direct evidence, since from the outside it's indistinguishable from a
+ // deliberate B-press unlock.
+ if (isAdjustmentLocked) {
+ Timber.i("QuickMenu: row '%s' lost focus while locked — force-unlocking", title)
+ }
isAdjustmentLocked = false
}
}
@@ -1525,12 +1916,29 @@ private fun QuickMenuAdjustmentRow(
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN && isFocused) {
when {
- keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_A -> {
- isAdjustmentLocked = !isAdjustmentLocked
+ // A only LOCKS, never toggles off — a repeated A press (whether the user
+ // meant to confirm again, or it arrived via the DPAD_CENTER/BUTTON_A
+ // fallback dispatched for the "nothing else focused" case, see
+ // ImmersiveXrActivity's BUTTON_A handling) must never undo an in-progress
+ // adjustment. Only BUTTON_B or losing focus unlocks — see below and
+ // onFocusChanged. Confirmed via logs: this WAS toggling off mid-adjustment
+ // ("row lock toggled to false" from a genuine BUTTON_A press, not B),
+ // exactly matching "one tick works, then I need B to get back in".
+ !isAdjustmentLocked && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_A -> {
+ isAdjustmentLocked = true
+ Timber.i("QuickMenu: row '%s' locked", title)
true
}
- isAdjustmentLocked && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_B -> {
+ // ImmersiveXrActivity's B handler dispatches KEYCODE_BACK, not
+ // KEYCODE_BUTTON_B (see triggerMenuDirection's caller) — this never
+ // matched before, so B was "working" only by accident: the unconsumed
+ // BACK event propagated further and cleared focus, which onFocusChanged
+ // then read as an unlock. Checking BACK directly makes this an explicit,
+ // consumed unlock instead of a side effect of losing focus.
+ isAdjustmentLocked &&
+ (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_B ||
+ keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BACK) -> {
isAdjustmentLocked = false
true
}
@@ -1770,7 +2178,6 @@ private fun QuickMenuToggleRow(
indication = null,
onClick = onToggle,
)
- .focusable(interactionSource = interactionSource)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
@@ -1995,10 +2402,6 @@ private fun QuickMenuItemRow(
indication = null,
onClick = onClick,
)
- .focusable(
- enabled = isEnabled,
- interactionSource = interactionSource,
- )
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
diff --git a/app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt b/app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt
index 6389ad6429..f54522dd2b 100644
--- a/app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt
+++ b/app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt
@@ -974,6 +974,15 @@ private fun ScreenEffectAdjustmentRow(
val accentColor = PluviaTheme.colors.accentPurple
val shape = RoundedCornerShape(14.dp)
var isAdjustmentLocked by remember { mutableStateOf(false) }
+ // See QuickMenu.kt's LocalImmersiveInputBypass kdoc (identical mechanism as
+ // QuickMenuAdjustmentRow, kept in sync) — reports (onDecrease, onIncrease) up while this row
+ // is both focused and lock-toggled, null otherwise, so the Meta Quest immersive activity
+ // (which bypasses this row's own onPreviewKeyEvent entirely for arrow keys, since synthetic
+ // dpad KeyEvents never reach Compose's key dispatch in that Activity) can still drive it.
+ val inputBypass = LocalImmersiveInputBypass.current
+ LaunchedEffect(isFocused, isAdjustmentLocked) {
+ inputBypass.reportAdjustment(if (isFocused && isAdjustmentLocked) (onDecrease to onIncrease) else null)
+ }
Column(
modifier = Modifier
@@ -1023,12 +1032,20 @@ private fun ScreenEffectAdjustmentRow(
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN && isFocused) {
when {
- keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_A -> {
- isAdjustmentLocked = !isAdjustmentLocked
+ // A only LOCKS, never toggles off — see QuickMenuAdjustmentRow's identical
+ // handler for why (a repeated A press must never undo an in-progress
+ // adjustment). Only BUTTON_B or losing focus unlocks.
+ !isAdjustmentLocked && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_A -> {
+ isAdjustmentLocked = true
true
}
- isAdjustmentLocked && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_B -> {
+ // ImmersiveXrActivity's B handler dispatches KEYCODE_BACK, not
+ // KEYCODE_BUTTON_B — see QuickMenuAdjustmentRow's identical handler for
+ // why both are checked here.
+ isAdjustmentLocked &&
+ (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_B ||
+ keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BACK) -> {
isAdjustmentLocked = false
true
}
@@ -1286,6 +1303,17 @@ private fun ScreenEffectRadioRow(
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
+ // Reports onSelect up while this row is focused, null otherwise — see
+ // LocalImmersiveInputBypass's kdoc (QuickMenu.kt): a plain .selectable() relies on Android's
+ // default "DPAD_CENTER/A clicks whatever holds real view focus" behavior, which the Meta
+ // Quest immersive activity found to be unreliable for this exact case (real Android view
+ // focus doesn't always track Compose's own internal focus there — confirmed separately for
+ // tab-rail buttons and adjustment-row locks earlier this session). Lets that Activity invoke
+ // onSelect directly instead of gambling on the default click path.
+ val inputBypass = LocalImmersiveInputBypass.current
+ LaunchedEffect(isFocused) {
+ inputBypass.reportActivate(if (isFocused) onSelect else null)
+ }
val accentColor = PluviaTheme.colors.accentPurple
val shape = RoundedCornerShape(14.dp)
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt
index 2384978ad3..0951f7c800 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt
@@ -279,6 +279,7 @@ fun ContainerConfigDialog(
default: Boolean = false,
title: String,
initialConfig: ContainerData = ContainerData(),
+ hasAndroidVersion: Boolean = false,
onDismissRequest: () -> Unit,
onSave: (ContainerData) -> Unit,
) {
@@ -378,6 +379,7 @@ fun ContainerConfigDialog(
val installedLists = availability?.installed
val isBionicVariant = config.containerVariant.equals(Container.BIONIC, ignoreCase = true)
+ val isAndroidPlatform = config.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)
val manifestDownloadMessage = if (manifestDownloadLabel.isNotEmpty()) {
stringResource(R.string.manifest_downloading_item, manifestDownloadLabel)
} else {
@@ -1175,6 +1177,8 @@ fun ContainerConfigDialog(
gpuExtensions = gpuExtensions,
inspectionMode = inspectionMode,
isBionicVariant = isBionicVariant,
+ isAndroidPlatform = isAndroidPlatform,
+ hasAndroidVersion = hasAndroidVersion,
nonDeletableDriveLetters = nonDeletableDriveLetters,
availableDriveLetters = availableDriveLetters,
launchManifestInstall = { entry, label, isDriver, expectedType, onInstalled ->
@@ -1252,6 +1256,12 @@ fun ContainerConfigDialog(
},
) { paddingValues ->
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
+ // Android platform doesn't use Wine/Box64 at all: force the user back to
+ // General (where the platform picker lives) so they can't get stuck on a
+ // now-disabled tab.
+ LaunchedEffect(isAndroidPlatform) {
+ if (isAndroidPlatform) selectedTab = 0
+ }
val tabs = listOf(
stringResource(R.string.container_config_tab_general),
stringResource(R.string.container_config_tab_graphics),
@@ -1280,11 +1290,11 @@ fun ContainerConfigDialog(
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.ButtonR1, Key.ButtonR2 -> {
- selectedTab = (selectedTab + 1) % tabs.size
+ if (!isAndroidPlatform) selectedTab = (selectedTab + 1) % tabs.size
true
}
Key.ButtonL1, Key.ButtonL2 -> {
- selectedTab = (selectedTab - 1 + tabs.size) % tabs.size
+ if (!isAndroidPlatform) selectedTab = (selectedTab - 1 + tabs.size) % tabs.size
true
}
else -> false
@@ -1302,6 +1312,7 @@ fun ContainerConfigDialog(
tabs.forEachIndexed { index, label ->
Tab(
selected = selectedTab == index,
+ enabled = index == 0 || !isAndroidPlatform,
onClick = { selectedTab = index },
text = { Text(text = label) },
modifier = if (index == 0) {
@@ -1397,12 +1408,19 @@ internal fun ExecutablePathDropdown(
value: String,
onValueChange: (String) -> Unit,
containerData: ContainerData,
+ enabled: Boolean = true,
) {
var expanded by remember { mutableStateOf(false) }
var executables by remember { mutableStateOf>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
val context = LocalContext.current
+ // Otherwise a stale `expanded = true` from before this field was disabled would keep the
+ // menu open (and its items selectable) even though it's now greyed out.
+ LaunchedEffect(enabled) {
+ if (!enabled) expanded = false
+ }
+
// Load executables from A: drive when component is first created
LaunchedEffect(containerData.drives) {
isLoading = true
@@ -1413,14 +1431,15 @@ internal fun ExecutablePathDropdown(
}
ExposedDropdownMenuBox(
- expanded = expanded,
- onExpandedChange = { expanded = it },
+ expanded = expanded && enabled,
+ onExpandedChange = { if (enabled) expanded = it },
modifier = modifier
) {
NoExtractOutlinedTextField(
value = value,
onValueChange = onValueChange,
readOnly = true,
+ enabled = enabled,
label = { Text(stringResource(R.string.container_config_executable_path)) },
placeholder = { Text(stringResource(R.string.container_config_executable_path_placeholder)) },
trailingIcon = {
@@ -1437,7 +1456,7 @@ internal fun ExecutablePathDropdown(
// so the anchor never opens via controller. Intercept it here and toggle
// the menu. (Up/down focus-escape is handled in NoExtractOutlinedTextField.)
.onPreviewKeyEvent { event ->
- if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ if (event.type != KeyEventType.KeyDown || !enabled) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.Spacebar, Key.ButtonA -> {
expanded = !expanded
@@ -1451,7 +1470,7 @@ internal fun ExecutablePathDropdown(
if (!isLoading && executables.isNotEmpty()) {
ExposedDropdownMenu(
- expanded = expanded,
+ expanded = expanded && enabled,
onDismissRequest = { expanded = false }
) {
executables.forEach { executable ->
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt
index c8d34010b3..df067886b0 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt
@@ -127,6 +127,8 @@ class ContainerConfigState(
val gpuExtensions: List,
val inspectionMode: Boolean,
val isBionicVariant: Boolean,
+ val isAndroidPlatform: Boolean,
+ val hasAndroidVersion: Boolean,
val nonDeletableDriveLetters: Set,
val availableDriveLetters: List,
val launchManifestInstall: (ManifestEntry, String, Boolean, ContentProfile.ContentType?, () -> Unit) -> Unit,
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt
index 679ad64420..49ef66474d 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt
@@ -116,7 +116,7 @@ fun GameManagerDialog(
allDownloadableApps.clear()
// Get Downloadable Depots
- val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId)
+ val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId))
downloadableDepots.putAll(allPossibleDownloadableDepots)
// Get Optional DLC IDs
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt
index 66653ed568..0981d8af87 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt
@@ -137,7 +137,28 @@ fun GeneralTabContent(
)
}
- SettingsGroup() {
+ SettingsGroup {
+ if (state.hasAndroidVersion) {
+ val platformItems = listOf(
+ stringResource(R.string.container_platform_normal),
+ stringResource(R.string.container_platform_android),
+ )
+ val platformIndex = if (config.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)) 1 else 0
+ SettingsListDropdown(
+ colors = settingsTileColors(),
+ title = { Text(text = stringResource(R.string.container_platform)) },
+ subtitle = { Text(text = stringResource(R.string.container_platform_description)) },
+ value = platformIndex,
+ items = platformItems,
+ enabled = true,
+ onItemSelected = { idx ->
+ val newPlatform = if (idx == 1) Container.PLATFORM_ANDROID else Container.PLATFORM_WINDOWS
+ state.config.value = config.copy(platform = newPlatform)
+ },
+ )
+ }
+ }
+ SettingsGroup(enabled = !state.isAndroidPlatform) {
run {
val variantIndex = rememberSaveable {
mutableIntStateOf(
@@ -262,11 +283,13 @@ fun GeneralTabContent(
value = config.executablePath,
onValueChange = { state.config.value = config.copy(executablePath = it) },
containerData = config,
+ enabled = !state.isAndroidPlatform,
)
NoExtractOutlinedTextField(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
value = config.execArgs,
onValueChange = { state.config.value = config.copy(execArgs = it) },
+ enabled = !state.isAndroidPlatform,
label = { Text(text = stringResource(R.string.exec_arguments)) },
placeholder = { Text(text = stringResource(R.string.exec_arguments_example)) },
singleLine = true,
@@ -282,7 +305,6 @@ fun GeneralTabContent(
}
}
SettingsListDropdown(
- enabled = true,
value = state.languageIndex.value,
items = state.languages.map(displayNameForLanguage),
fallbackDisplay = displayNameForLanguage("english"),
diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt
index ec17d918a5..4c138af897 100644
--- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt
+++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt
@@ -1,5 +1,6 @@
package app.gamenative.ui.data
+import app.gamenative.BuildConfig
import app.gamenative.PrefManager
import app.gamenative.data.GameCompatibilityStatus
import app.gamenative.data.GameSource
@@ -11,8 +12,22 @@ import app.gamenative.ui.enums.LibraryTab
import app.gamenative.ui.enums.SortOption
import java.util.EnumSet
+/**
+ * Modern/ModernXr can't show or toggle the Android filter (see LibraryOptionsPanel), but a
+ * persisted selection from a Legacy install — or a restored backup — could still carry the flag.
+ * Strip it here so it can never silently restrict/empty the library with no way to turn it off.
+ */
+private fun sanitizedLibraryFilter(): EnumSet {
+ val stored = PrefManager.libraryFilter
+ return if (BuildConfig.MODERN_ANDROID && stored.contains(AppFilter.ANDROID)) {
+ EnumSet.copyOf(stored).apply { remove(AppFilter.ANDROID) }
+ } else {
+ stored
+ }
+}
+
data class LibraryState(
- val appInfoSortType: EnumSet = PrefManager.libraryFilter,
+ val appInfoSortType: EnumSet = sanitizedLibraryFilter(),
val appInfoList: List = emptyList(),
val isRefreshing: Boolean = false,
diff --git a/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt b/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt
index 662b08256b..d78cd779a7 100644
--- a/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt
+++ b/app/src/main/java/app/gamenative/ui/enums/AppFilter.kt
@@ -1,6 +1,7 @@
package app.gamenative.ui.enums
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.AvTimer
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Computer
@@ -84,6 +85,11 @@ enum class AppFilter(
displayTextRes = R.string.filter_proven_gpu,
icon = Icons.Rounded.SportsEsports,
),
+ ANDROID(
+ code = 0x1000,
+ displayTextRes = R.string.app_filter_android,
+ icon = Icons.Default.Android,
+ ),
// ALPHABETIC(
// code = 0x20,
// displayText = "Alphabetic",
diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt
index 884332a57d..6306d70013 100644
--- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt
+++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt
@@ -626,6 +626,15 @@ class LibraryViewModel @Inject constructor(
true
}
}
+ .filter { item ->
+ // item.depots is already deserialized in memory on the SteamApp we just
+ // loaded, so this check is free — no per-item DB lookup.
+ if (currentState.appInfoSortType.contains(AppFilter.ANDROID)) {
+ item.depots.values.any { it.isAndroidCompatible }
+ } else {
+ true
+ }
+ }
.toList()
// Per-collection counts: computed from the owner/type/search-filtered set (independent of the
@@ -946,12 +955,17 @@ class LibraryViewModel @Inject constructor(
// sources can't match it — keep them out of the combined list (and their tab counts).
val steamCollectionSelected = allowedSteamAppIds != null
+ // The Android depot feature only exists for Steam apps (it's a Steam Frame / Lepton
+ // concept), so the other sources can never match this filter either.
+ val androidFilterActive = currentState.appInfoSortType.contains(AppFilter.ANDROID)
+ val excludeNonSteam = steamCollectionSelected || androidFilterActive
+
val combined = buildList {
if (includeSteam) addAll(steamEntries)
- if (includeOpen && !steamCollectionSelected) addAll(customEntries)
- if (includeGOG && !steamCollectionSelected) addAll(gogEntries)
- if (includeEpic && !steamCollectionSelected) addAll(epicEntries)
- if (includeAmazon && !steamCollectionSelected) addAll(amazonEntries)
+ if (includeOpen && !excludeNonSteam) addAll(customEntries)
+ if (includeGOG && !excludeNonSteam) addAll(gogEntries)
+ if (includeEpic && !excludeNonSteam) addAll(epicEntries)
+ if (includeAmazon && !excludeNonSteam) addAll(amazonEntries)
}.sortedWith(sortComparator).mapIndexed { idx, entry ->
entry.item.copy(index = idx, isInstalled = entry.isInstalled)
}
diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
index 4b7fb79617..4702c33f14 100644
--- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
+++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.gamenative.PluviaApp
import app.gamenative.PrefManager
+import app.gamenative.R
import app.gamenative.data.GameProcessInfo
import app.gamenative.data.GameSource
import app.gamenative.data.LibraryPlayHistory
@@ -29,11 +30,14 @@ import app.gamenative.utils.CustomGameScanner
import app.gamenative.ui.data.MainState
import app.gamenative.ui.enums.ConnectionState
import app.gamenative.ui.screen.PluviaScreen
+import app.gamenative.ui.util.SnackbarManager
+import app.gamenative.utils.AndroidGameLauncher
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.IntentLaunchManager
import app.gamenative.utils.SteamUtils
import app.gamenative.utils.UpdateInfo
import com.materialkolor.PaletteStyle
+import com.winlator.container.Container
import com.winlator.xserver.Window
import dagger.hilt.android.lifecycle.HiltViewModel
import `in`.dragonbra.javasteam.steam.handlers.steamapps.AppProcessInfo
@@ -220,6 +224,18 @@ class MainViewModel @Inject constructor(
setShowBootingSplash(true)
}
+ // ImmersiveXrActivity hosts its own separate MainViewModel instance (different Activity) —
+ // its exit/onWindowMapped/onGameLaunchError calls only reset *that* instance's splash, while
+ // MainActivity's own (backgrounded but still subscribed to SetBootingSplashText) instance
+ // keeps getting re-armed by every splash-text update XServerScreen emits during the immersive
+ // session's boot sequence, and is never told the session ended. Broadcasting this on the same
+ // global bus lets every subscribed instance — whichever Activity it belongs to — clear itself.
+ private val onClearBootingSplash: (AndroidEvent.ClearBootingSplash) -> Unit = {
+ bootingSplashTimeoutJob?.cancel()
+ bootingSplashTimeoutJob = null
+ setShowBootingSplash(false)
+ }
+
private var bootingSplashTimeoutJob: Job? = null
private var connectionTimeoutJob: Job? = null
@@ -254,6 +270,7 @@ class MainViewModel @Inject constructor(
PluviaApp.events.on(onBackPressed)
PluviaApp.events.on(onExternalGameLaunch)
PluviaApp.events.on(onSetBootingSplashText)
+ PluviaApp.events.on(onClearBootingSplash)
PluviaApp.events.on(onSteamConnected)
PluviaApp.events.on(onSteamDisconnected)
PluviaApp.events.on(onRemotelyDisconnected)
@@ -280,6 +297,7 @@ class MainViewModel @Inject constructor(
PluviaApp.events.off(onBackPressed)
PluviaApp.events.off(onExternalGameLaunch)
PluviaApp.events.off(onSetBootingSplashText)
+ PluviaApp.events.off(onClearBootingSplash)
PluviaApp.events.off(onSteamConnected)
PluviaApp.events.off(onSteamDisconnected)
PluviaApp.events.off(onRemotelyDisconnected)
@@ -462,8 +480,8 @@ class MainViewModel @Inject constructor(
}
fun launchApp(context: Context, appId: String) {
- // Show booting splash before launching the app
viewModelScope.launch {
+ // Shared by both the Android and Wine paths below.
viewModelScope.launch(Dispatchers.IO) {
libraryPlayHistoryDao.upsert(
LibraryPlayHistory(
@@ -473,6 +491,26 @@ class MainViewModel @Inject constructor(
)
}
+ // getOrCreateContainerWithOverride also picks up a temporary per-launch config
+ // (e.g. from an external Intent launch) — falls back to the persisted container
+ // untouched when there's no override, so this is safe unconditionally.
+ val container = withContext(Dispatchers.IO) {
+ ContainerUtils.getOrCreateContainerWithOverride(context, appId)
+ }
+ if (container.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)) {
+ // Native Android (Steam Frame / Lepton) build: no Wine container involved at all.
+ val gameId = ContainerUtils.extractGameIdFromContainerId(appId)
+ when (withContext(Dispatchers.IO) { AndroidGameLauncher.installAndLaunch(context, gameId) }) {
+ AndroidGameLauncher.Result.InstallStarted ->
+ SnackbarManager.show(context.getString(R.string.android_game_install_started))
+ AndroidGameLauncher.Result.Failed ->
+ SnackbarManager.show(context.getString(R.string.android_game_launch_failed))
+ AndroidGameLauncher.Result.Launched -> Unit
+ }
+ return@launch
+ }
+
+ // Show booting splash before launching the app
setShowBootingSplash(true)
PluviaApp.events.emit(AndroidEvent.SetAllowedOrientation(PrefManager.allowedOrientation))
@@ -535,7 +573,30 @@ class MainViewModel @Inject constructor(
apiJob.await()
- _uiEvent.send(MainUiEvent.LaunchApp)
+ if (container.isLaunchImmersiveMode() && app.gamenative.MainActivity.isMetaQuest(context)) {
+ // Immersive/VR launches always boot straight to the executable — there is no
+ // "open the Wine desktop" variant for this entry point. Placed after apiJob so
+ // the Steam DRM/API patch step above (replaceSteamApi/replaceSteamclientDll)
+ // still runs — skipping it made steamclient_loader_x64.exe fail to find files
+ // it depends on, for every game, not just DRM-heavy ones.
+ //
+ // ImmersiveXrActivity has its own separate MainViewModel instance (different
+ // Activity), so it can't clear *this* instance's booting splash — MainActivity
+ // is about to lose focus to that Activity anyway, so just hide it now instead
+ // of leaving it stuck on "Launching..." forever.
+ bootingSplashTimeoutJob?.cancel()
+ bootingSplashTimeoutJob = null
+ setShowBootingSplash(false)
+ // MainActivity is about to onStop() once ImmersiveXrActivity takes focus; its
+ // onStop/onDestroy checks treat !keepAlive as "nothing running, disconnect Steam"
+ // (see MainActivity.kt onStop/onDestroy), which was silently logging the whole
+ // session off for the entire immersive session — forcing a full relogin + license
+ // resync on return. shutdownEnvironment() already clears this back to false on exit.
+ SteamService.keepAlive = true
+ app.gamenative.ui.screen.xr.ImmersiveXrActivity.start(context, appId, _offline.value)
+ } else {
+ _uiEvent.send(MainUiEvent.LaunchApp)
+ }
}
}
@@ -546,6 +607,11 @@ class MainViewModel @Inject constructor(
bootingSplashTimeoutJob?.cancel()
bootingSplashTimeoutJob = null
setShowBootingSplash(false)
+ // Also broadcast globally: when this runs from ImmersiveXrActivity's own separate
+ // MainViewModel instance, MainActivity's (backgrounded but still subscribed to
+ // SetBootingSplashText) instance never otherwise learns the session ended — see
+ // onClearBootingSplash's kdoc.
+ PluviaApp.events.emit(AndroidEvent.ClearBootingSplash)
// Check if we have a temporary override before doing anything
val hadTemporaryOverride = IntentLaunchManager.hasTemporaryOverride(appId)
@@ -670,6 +736,9 @@ class MainViewModel @Inject constructor(
bootingSplashTimeoutJob?.cancel()
bootingSplashTimeoutJob = null
setShowBootingSplash(false)
+ // See onClearBootingSplash's kdoc — broadcast so MainActivity's own instance clears
+ // too when this call is actually running on ImmersiveXrActivity's separate instance.
+ PluviaApp.events.emit(AndroidEvent.ClearBootingSplash)
val gameId = ContainerUtils.extractGameIdFromContainerId(appId)
@@ -732,6 +801,9 @@ class MainViewModel @Inject constructor(
bootingSplashTimeoutJob?.cancel()
bootingSplashTimeoutJob = null
setShowBootingSplash(false)
+ // See onClearBootingSplash's kdoc — broadcast so MainActivity's own instance clears
+ // too when this call is actually running on ImmersiveXrActivity's separate instance.
+ PluviaApp.events.emit(AndroidEvent.ClearBootingSplash)
// You could also show an error dialog here if needed
Timber.tag("MainViewModel").e("Game launch error: $error")
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt
index 55b94093b1..ea8a747c1a 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt
@@ -61,6 +61,8 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
@@ -552,6 +554,16 @@ private fun formatBytes(bytes: Long): String {
}
}
+// Bundled into one parameter to keep AppScreenContent's own parameter count with headroom
+// below the threshold where Compose starts emitting a second "changed" bitmask int — this
+// composable has crashed with an ART VerifyError on API < 29 (Meta Quest legacyXr) once it
+// crossed that line.
+internal data class ImmersiveModeUiState(
+ val isSupported: Boolean = false,
+ val isEnabled: Boolean = false,
+ val onChange: (Boolean) -> Unit = {},
+)
+
@Composable
internal fun AppScreenContent(
modifier: Modifier = Modifier,
@@ -571,6 +583,7 @@ internal fun AppScreenContent(
onBack: () -> Unit = {},
optionsMenu: List,
dialogOpen: Boolean = false,
+ immersiveMode: ImmersiveModeUiState = ImmersiveModeUiState(),
) {
val context = LocalContext.current
// reactive — recomposes when network state changes
@@ -971,6 +984,29 @@ internal fun AppScreenContent(
}
}
+ if (immersiveMode.isSupported && isInstalled) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .clickable { immersiveMode.onChange(!immersiveMode.isEnabled) },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(
+ checked = immersiveMode.isEnabled,
+ onCheckedChange = immersiveMode.onChange,
+ colors = CheckboxDefaults.colors(
+ uncheckedColor = Color.White.copy(alpha = 0.7f),
+ ),
+ )
+ Text(
+ text = stringResource(R.string.launch_immersive_mode),
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White,
+ )
+ }
+ }
+
if (isDownloading && isPortrait) {
Row(
modifier = Modifier
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt
index 03d6f40f59..93222e2942 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt
@@ -14,6 +14,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -606,6 +607,12 @@ abstract class BaseAppScreen {
protected open fun supportsSaveTransfer(libraryItem: LibraryItem): Boolean = false
+ /** Whether this game also has a native Android (Steam Frame / Lepton) depot available. */
+ open fun hasAndroidVersion(libraryItem: LibraryItem): Boolean = false
+
+ /** Package name of the Android app installed/installing for this game, or null if not applicable. */
+ open fun getAndroidPackageName(context: Context, libraryItem: LibraryItem): String? = null
+
protected open suspend fun exportSaves(
context: Context,
libraryItem: LibraryItem,
@@ -1045,8 +1052,39 @@ abstract class BaseAppScreen {
mutableStateOf(hasLeftoverInstall(context, libraryItem))
}
+ // Immersive/VR launch mode is only offered on Meta Quest, and only for games running
+ // through the Wine/Proton path — a native Android (Steam Frame) install never goes
+ // through ImmersiveXrActivity (MainViewModel returns early for those, before ever
+ // looking at this flag), so offering the toggle there would be misleading.
+ val isImmersiveModeSupported = remember(libraryItem.appId) {
+ app.gamenative.MainActivity.isMetaQuest(context) &&
+ !app.gamenative.service.SteamService.isAndroidPlatform(libraryItem.gameId)
+ }
+ var isImmersiveModeEnabledState by remember(libraryItem.appId) { mutableStateOf(false) }
+ if (isImmersiveModeSupported) {
+ LaunchedEffect(libraryItem.appId) {
+ isImmersiveModeEnabledState = withContext(Dispatchers.IO) {
+ runCatching { ContainerUtils.getContainer(context, libraryItem.appId).isLaunchImmersiveMode() }
+ .getOrDefault(false)
+ }
+ }
+ }
+
+ // hasAndroidVersion() can hit the database (SteamService.getAppInfoOf does a blocking
+ // Room query) — compute it off the main thread once per screen visit, instead of
+ // re-running it synchronously on the main thread every time Edit Container is opened.
+ var hasAndroidVersionState by remember(libraryItem.appId) { mutableStateOf(false) }
+ LaunchedEffect(libraryItem.appId) {
+ hasAndroidVersionState = withContext(Dispatchers.IO) { hasAndroidVersion(libraryItem) }
+ }
+
val uiScope = rememberCoroutineScope()
+ // Increments on every refresh, unlike isDownloadingState/isInstalledState which can flip
+ // true->false within the same recomposition on a very fast download and never be observed
+ // by an effect keyed on them — this always counts as a distinct key.
+ var stateRefreshGeneration by remember(libraryItem.appId) { mutableIntStateOf(0) }
+
suspend fun performStateRefresh(includeUpdatePending: Boolean) {
isInstalledState = isInstalled(context, libraryItem)
isValidToDownloadState = isValidToDownload(context, libraryItem)
@@ -1058,6 +1096,7 @@ abstract class BaseAppScreen {
if (includeUpdatePending) {
isUpdatePendingState = isUpdatePendingSuspend(context, libraryItem)
}
+ stateRefreshGeneration++
}
fun requestStateRefresh(includeUpdatePending: Boolean) {
@@ -1070,6 +1109,42 @@ abstract class BaseAppScreen {
performStateRefresh(true)
}
+ // Android platform games: the actual install happens in the system's own installer UI,
+ // outside our control, so listen for its completion broadcast instead of requiring the
+ // user to leave and come back to this screen for the Play button to appear.
+ // Keyed on stateRefreshGeneration: getAndroidPackageName() reads the APK off disk, which
+ // is null until the download finishes, so the effect needs to re-run once that changes
+ // rather than being stuck with the null result from the initial composition.
+ DisposableEffect(libraryItem.appId, stateRefreshGeneration) {
+ val androidPackageName = getAndroidPackageName(context, libraryItem)
+ if (androidPackageName == null) {
+ onDispose { }
+ } else {
+ val receiver = object : android.content.BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ if (intent.data?.schemeSpecificPart == androidPackageName) {
+ requestStateRefresh(true)
+ }
+ }
+ }
+ val filter = android.content.IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REPLACED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addDataScheme("package")
+ }
+ androidx.core.content.ContextCompat.registerReceiver(
+ context,
+ receiver,
+ filter,
+ androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
+ onDispose {
+ context.unregisterReceiver(receiver)
+ }
+ }
+ }
+
var showConfigDialog by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf(false)
}
@@ -1078,8 +1153,14 @@ abstract class BaseAppScreen {
}
val onEditContainer: () -> Unit = {
- containerData = loadContainerData(context, libraryItem)
- showConfigDialog = true
+ // loadContainerData() reads/parses the container's JSON off disk (and may create it
+ // on first access) — do that off the main thread so opening this dialog doesn't
+ // freeze the screen while it loads.
+ uiScope.launch {
+ val data = withContext(Dispatchers.IO) { loadContainerData(context, libraryItem) }
+ containerData = data
+ showConfigDialog = true
+ }
}
// Export for Frontend launcher
@@ -1333,6 +1414,20 @@ abstract class BaseAppScreen {
hasLeftoverInstall = hasLeftoverInstallState,
isUpdatePending = isUpdatePendingState,
downloadInfo = downloadInfo,
+ immersiveMode = app.gamenative.ui.screen.library.ImmersiveModeUiState(
+ isSupported = isImmersiveModeSupported,
+ isEnabled = isImmersiveModeEnabledState,
+ onChange = { enabled ->
+ isImmersiveModeEnabledState = enabled
+ uiScope.launch(Dispatchers.IO) {
+ runCatching {
+ val container = ContainerUtils.getContainer(context, libraryItem.appId)
+ container.setLaunchImmersiveMode(enabled)
+ container.saveData()
+ }
+ }
+ },
+ ),
onDownloadInstallClick = {
if (app.gamenative.launch.LaunchReadiness.pending) {
showReadiness = true
@@ -1380,6 +1475,7 @@ abstract class BaseAppScreen {
ContainerConfigDialog(
title = "${displayInfo.name} Config",
initialConfig = containerData,
+ hasAndroidVersion = hasAndroidVersionState,
onDismissRequest = { showConfigDialog = false },
onSave = {
saveContainerConfig(context, libraryItem, it)
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt
index 0d5e3c987e..2932878733 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt
@@ -57,6 +57,7 @@ import app.gamenative.ui.data.AppMenuOption
import app.gamenative.ui.data.GameDisplayInfo
import app.gamenative.ui.enums.AppOptionMenuType
import app.gamenative.ui.enums.DialogType
+import app.gamenative.utils.AndroidGameLauncher
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.MarkerUtils
import app.gamenative.utils.SteamUtils
@@ -226,8 +227,20 @@ class SteamAppScreen : BaseAppScreen() {
return pendingUpdateVerifyOperations[gameId]
}
- // Shared state for deletion progress dialog
- var showDeletingDialog by mutableStateOf(false)
+ // Shared state for deletion progress dialog - map of gameId to visibility
+ private val deletingDialogVisible = mutableStateMapOf()
+
+ fun showDeletingDialog(gameId: Int) {
+ deletingDialogVisible[gameId] = true
+ }
+
+ fun hideDeletingDialog(gameId: Int) {
+ deletingDialogVisible.remove(gameId)
+ }
+
+ fun isDeletingDialogVisible(gameId: Int): Boolean {
+ return deletingDialogVisible[gameId] == true
+ }
}
@Composable
@@ -251,11 +264,16 @@ class SteamAppScreen : BaseAppScreen() {
var isInstalled by remember(libraryItem.appId) {
mutableStateOf(SteamService.isAppInstalled(gameId))
}
+ // Bumped on every LibraryInstallStatusChanged, even when isInstalled itself doesn't
+ // change (e.g. platform switched on a not-yet-installed game) — sizeFromStore below
+ // depends on the container's platform, not just on install state.
+ var refreshTrigger by remember(libraryItem.appId) { mutableIntStateOf(0) }
DisposableEffect(gameId) {
val listener: (AndroidEvent.LibraryInstallStatusChanged) -> Unit = { event ->
if (event.appId == gameId) {
isInstalled = SteamService.isAppInstalled(gameId)
+ refreshTrigger++
}
}
PluviaApp.events.on(listener)
@@ -289,7 +307,7 @@ class SteamAppScreen : BaseAppScreen() {
// Get size on disk (async, will update via state)
var sizeOnDisk by remember { mutableStateOf(null) }
- LaunchedEffect(isInstalled, gameId) {
+ LaunchedEffect(isInstalled, gameId, refreshTrigger) {
if (isInstalled) {
DownloadService.getSizeOnDiskDisplay(gameId) {
sizeOnDisk = it
@@ -301,7 +319,7 @@ class SteamAppScreen : BaseAppScreen() {
// Get size from store (async, will update via state)
var sizeFromStore by remember { mutableStateOf(null) }
- LaunchedEffect(isInstalled, gameId) {
+ LaunchedEffect(isInstalled, gameId, refreshTrigger) {
if (!isInstalled) {
// Load size from store on IO, assign on Main to respect Compose threading
val size = withContext(Dispatchers.IO) {
@@ -367,7 +385,14 @@ class SteamAppScreen : BaseAppScreen() {
}
override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean {
- return SteamService.isAppInstalled(libraryItem.gameId)
+ val gameId = libraryItem.gameId
+ if (!SteamService.isAppInstalled(gameId)) return false
+ // Android platform: the depot being downloaded isn't the same as the app actually
+ // being installed on the system — only show Play once it really is.
+ if (SteamService.isAndroidPlatform(gameId)) {
+ return AndroidGameLauncher.isGameInstalled(context, gameId)
+ }
+ return true
}
override fun isValidToDownload(context: Context, libraryItem: LibraryItem): Boolean {
@@ -863,17 +888,69 @@ class SteamAppScreen : BaseAppScreen() {
override fun saveContainerConfig(context: Context, libraryItem: LibraryItem, config: ContainerData) {
val container = getContainer(context, libraryItem.appId)
+ val platformChanged = !container.platform.equals(config.platform, ignoreCase = true)
+ val wasAndroid = container.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)
+ val languageChanged = container.language != config.language
ContainerUtils.applyToContainer(context, libraryItem.appId, config)
- if (container.language != config.language) {
- CoroutineScope(Dispatchers.IO).launch {
- SteamService.downloadApp(libraryItem.gameId)
+ val gameId = libraryItem.gameId
+ when {
+ // Switching platform on an already-installed game: drop the old platform's files
+ // and fetch the newly selected one. Not installed yet -> just remember the choice,
+ // the Install/Play button will pick it up when the user actually installs.
+ platformChanged && SteamService.isAppInstalled(gameId) -> {
+ CoroutineScope(Dispatchers.IO).launch {
+ SnackbarManager.show(context.getString(R.string.container_platform_switch_reinstalling))
+ // Resolve and prompt removal of the installed Android app BEFORE its .apk is
+ // deleted below — that's the only place the package name can still be read
+ // from, and once the platform is switched away there's no other path back to
+ // it. Wait for confirmation: if the user cancels, leave the files/app alone
+ // rather than orphaning a still-installed app.
+ if (wasAndroid) {
+ val canDeleteFiles = withContext(Dispatchers.Main) {
+ AndroidGameLauncher.requestUninstall(context, gameId)
+ }
+ if (!canDeleteFiles) {
+ SnackbarManager.show(context.getString(R.string.android_game_uninstall_cancelled))
+ return@launch
+ }
+ AndroidGameLauncher.cleanupStagedApk(context, gameId)
+ }
+ SteamService.deleteApp(gameId)
+ DownloadService.invalidateCache()
+ SteamService.downloadApp(gameId)
+ }
+ }
+ languageChanged -> {
+ CoroutineScope(Dispatchers.IO).launch {
+ SteamService.downloadApp(gameId)
+ }
+ }
+ platformChanged -> {
+ // Not installed yet, nothing to redownload — but the store-size display on the
+ // game page reads the container's platform and needs a nudge to recompute now
+ // that it changed.
+ PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(gameId, GameSource.STEAM))
}
}
}
override fun supportsContainerConfig(): Boolean = true
+ override fun hasAndroidVersion(libraryItem: LibraryItem): Boolean {
+ // Modern/ModernXr strip the install/uninstall-package permissions (Horizon Store
+ // compliance), so they can't actually install a native Android build — keep the
+ // selector Legacy-only rather than offering a choice that can't work there.
+ if (BuildConfig.MODERN_ANDROID) return false
+ return SteamService.getAppInfoOf(libraryItem.gameId)?.depots?.values?.any { it.isAndroidCompatible } == true
+ }
+
+ override fun getAndroidPackageName(context: Context, libraryItem: LibraryItem): String? {
+ val gameId = libraryItem.gameId
+ if (!SteamService.isAndroidPlatform(gameId)) return null
+ return AndroidGameLauncher.resolvePackageName(context, gameId)
+ }
+
override fun getExportFileExtension(): String = ".steam"
@Composable
@@ -1008,7 +1085,7 @@ class SteamAppScreen : BaseAppScreen() {
}
}
- LaunchedEffect(gameId, hasStoragePermission) {
+ LaunchedEffect(gameId, hasStoragePermission, installDialogState.visible) {
if (!hasStoragePermission) {
installSizeInfo = null
return@LaunchedEffect
@@ -1017,7 +1094,8 @@ class SteamAppScreen : BaseAppScreen() {
val info = withContext(Dispatchers.IO) {
val container = ContainerManager(context).getContainerById("STEAM_$gameId")
val language = container?.language ?: PrefManager.containerLanguage
- val depots = SteamService.getDownloadableDepots(gameId, language)
+ val wantAndroid = container?.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)
+ val depots = SteamService.getDownloadableDepots(gameId, language, wantAndroid)
Timber.i("There are ${depots.size} depots belonging to ${libraryItem.appId}")
val branch = SteamService.getInstalledApp(gameId)?.branch ?: "public"
val availableBytes = StorageUtils.getAvailableSpaceForUncreatedPath(SteamService.getAppDirPath(gameId))
@@ -1128,7 +1206,7 @@ class SteamAppScreen : BaseAppScreen() {
downloadInfo?.cancel()
SteamService.workshopPausedApps.remove(gameId)
hideInstallDialog(gameId)
- showDeletingDialog = true
+ showDeletingDialog(gameId)
CoroutineScope(Dispatchers.IO).launch {
try {
SteamService.deleteApp(gameId)
@@ -1136,7 +1214,7 @@ class SteamAppScreen : BaseAppScreen() {
PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(gameId, GameSource.STEAM))
} finally {
withContext(NonCancellable + Dispatchers.Main) {
- showDeletingDialog = false
+ hideDeletingDialog(gameId)
}
}
}
@@ -1255,13 +1333,30 @@ class SteamAppScreen : BaseAppScreen() {
TextButton(
onClick = {
hideUninstallDialog(libraryItem.appId)
- showDeletingDialog = true
+ showDeletingDialog(gameId)
CoroutineScope(Dispatchers.IO).launch {
try {
val installedAppInfo = getInstalledApp(libraryItem.gameId)
val gameRootDir = getInstallPath(context, libraryItem)?.let(::File)
+ // The installed Android app (Steam Frame / Lepton games) is a
+ // separate system entity from GameNative's own downloaded copy —
+ // prompt its uninstall and wait for confirmation before we delete
+ // the .apk we need to resolve the package name from. Checked by
+ // looking for an actual .apk on disk rather than the container's
+ // current platform setting, which may have since been switched back.
+ val canDeleteFiles = withContext(Dispatchers.Main) {
+ AndroidGameLauncher.requestUninstall(context, gameId)
+ }
+ if (!canDeleteFiles) {
+ withContext(Dispatchers.Main) {
+ SnackbarManager.show(context.getString(R.string.android_game_uninstall_cancelled))
+ }
+ return@launch
+ }
+ AndroidGameLauncher.cleanupStagedApk(context, gameId)
+
val success = SteamService.deleteApp(gameId)
DownloadService.invalidateCache()
if (success) {
@@ -1296,7 +1391,7 @@ class SteamAppScreen : BaseAppScreen() {
}
} finally {
withContext(NonCancellable + Dispatchers.Main) {
- showDeletingDialog = false
+ hideDeletingDialog(gameId)
}
}
}
@@ -1325,7 +1420,7 @@ class SteamAppScreen : BaseAppScreen() {
}
// Deletion progress dialog
- if (showDeletingDialog) {
+ if (isDeletingDialogVisible(gameId)) {
LoadingDialog(
visible = true,
progress = -1f,
diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt
index 34c49efec9..1360226a17 100644
--- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt
@@ -73,6 +73,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import app.gamenative.BuildConfig
import app.gamenative.PrefManager
import app.gamenative.R
import app.gamenative.data.SteamCollection
@@ -259,18 +260,24 @@ fun LibraryOptionsPanel(
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
+ // Modern/ModernXr builds can't install a native Android build (Horizon
+ // Store compliance strips the install-package permissions), so this
+ // filter would just hide games with nothing wrong with them there.
+ val statusFilters = remember {
+ buildList {
+ add(AppFilter.INSTALLED)
+ add(AppFilter.SHARED)
+ add(AppFilter.COMPATIBLE)
+ add(AppFilter.EXPIRED)
+ add(AppFilter.PLAYABLE)
+ add(AppFilter.FIVE_STAR)
+ add(AppFilter.FIVE_STAR_GPU)
+ add(AppFilter.PROVEN_GPU)
+ if (!BuildConfig.MODERN_ANDROID) add(AppFilter.ANDROID)
+ }
+ }
AppFilter.entries.forEach { appFilter ->
- if (appFilter in listOf(
- AppFilter.INSTALLED,
- AppFilter.SHARED,
- AppFilter.COMPATIBLE,
- AppFilter.EXPIRED,
- AppFilter.PLAYABLE,
- AppFilter.FIVE_STAR,
- AppFilter.FIVE_STAR_GPU,
- AppFilter.PROVEN_GPU,
- )
- ) {
+ if (appFilter in statusFilters) {
OptionListItem(
text = stringResource(appFilter.displayTextRes),
selected = selectedFilters.contains(appFilter),
diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt b/app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt
new file mode 100644
index 0000000000..45fbce78e8
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt
@@ -0,0 +1,126 @@
+package app.gamenative.ui.screen.xr
+
+import android.hardware.HardwareBuffer
+import android.opengl.GLES20
+import com.winlator.renderer.GLHardwareBufferImporter
+import com.winlator.renderer.GLRenderer
+import com.winlator.renderer.XrFrameBridge
+import timber.log.Timber
+
+/**
+ * [XrFrameBridge] implementation for the legacy GL renderer path: allocates a
+ * [HardwareBuffer] (Android's cross-process/cross-context shareable GPU memory — the same
+ * mechanism Camera2/MediaCodec use to hand frames around without a CPU copy), imports it as a
+ * GL texture wrapped in an FBO *inside GLRenderer's own EGL context* (via
+ * [GLHardwareBufferImporter] — the public SDK has no usable Java binding for the
+ * eglGetNativeClientBufferANDROID/eglCreateImageKHR sequence this needs, so that import happens
+ * in native code), and hands the same buffer to the native OpenXR session so it can import it a
+ * second time into *its own* context and sample it directly — no PixelCopy, no CPU-side Bitmap,
+ * no glTexImage2D upload.
+ *
+ * Only used for GLRenderer (the legacy GL rendering path). Most containers default to
+ * dxvk (D3D->Vulkan), which renders through VulkanRenderer instead — a completely separate,
+ * Vulkan-native presentation path this bridge does not cover. That's a distinct, larger
+ * follow-up (needs OpenXR's Vulkan interop, not this GLES-based approach) — see the
+ * conversation this was scoped in. This class exists to validate the whole
+ * "render directly into a GPU buffer the XR session samples" approach end-to-end first, on the
+ * simpler of the two renderers.
+ */
+class DirectGLBridge(
+ private val onBufferReady: (HardwareBuffer) -> Unit,
+) : XrFrameBridge {
+
+ private var width = 0
+ private var height = 0
+ private var hardwareBuffer: HardwareBuffer? = null
+ private var texture = 0
+ private var framebuffer = 0
+ private var initFailed = false
+ private var announcedReady = false
+
+ /** Must be called from GLRenderer's own GL thread (e.g. via GLSurfaceView.queueEvent) —
+ * everything here (buffer import, texture/FBO creation) needs the calling EGL context to be
+ * current. Safe to call again with a new size; the old buffer/resources are released first. */
+ fun ensureAllocated(targetWidth: Int, targetHeight: Int) {
+ if (initFailed) return
+ if (hardwareBuffer != null && targetWidth == width && targetHeight == height) return
+ if (targetWidth <= 0 || targetHeight <= 0) return
+
+ release()
+ width = targetWidth
+ height = targetHeight
+
+ val buffer = try {
+ HardwareBuffer.create(
+ width,
+ height,
+ HardwareBuffer.RGBA_8888,
+ 1,
+ HardwareBuffer.USAGE_GPU_COLOR_OUTPUT or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE,
+ )
+ } catch (t: Throwable) {
+ Timber.e(t, "Immersive: DirectGLBridge failed to allocate HardwareBuffer %dx%d — falling back to PixelCopy", width, height)
+ initFailed = true
+ return
+ }
+
+ val importedTexture = GLHardwareBufferImporter.importAsTexture(buffer)
+ if (importedTexture == 0) {
+ Timber.e("Immersive: DirectGLBridge native import failed — falling back to PixelCopy")
+ buffer.close()
+ initFailed = true
+ return
+ }
+
+ val framebuffers = IntArray(1)
+ GLES20.glGenFramebuffers(1, framebuffers, 0)
+ GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, framebuffers[0])
+ GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, importedTexture, 0)
+ val fboStatus = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER)
+ GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
+ if (fboStatus != GLES20.GL_FRAMEBUFFER_COMPLETE) {
+ Timber.e("Immersive: DirectGLBridge FBO incomplete (0x%x) — falling back to PixelCopy", fboStatus)
+ GLES20.glDeleteTextures(1, intArrayOf(importedTexture), 0)
+ GLES20.glDeleteFramebuffers(1, framebuffers, 0)
+ buffer.close()
+ initFailed = true
+ return
+ }
+
+ hardwareBuffer = buffer
+ texture = importedTexture
+ framebuffer = framebuffers[0]
+ Timber.i("Immersive: DirectGLBridge allocated shared buffer %dx%d, fbo=%d texture=%d", width, height, framebuffer, texture)
+
+ if (!announcedReady) {
+ announcedReady = true
+ onBufferReady(buffer)
+ }
+ }
+
+ override fun beginFrame(): IntArray? {
+ val fbo = framebuffer
+ if (fbo == 0) return null
+ GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fbo)
+ return intArrayOf(width, height)
+ }
+
+ override fun endFrame() {
+ GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
+ }
+
+ fun release() {
+ if (texture != 0) GLES20.glDeleteTextures(1, intArrayOf(texture), 0)
+ if (framebuffer != 0) GLES20.glDeleteFramebuffers(1, intArrayOf(framebuffer), 0)
+ hardwareBuffer?.close()
+ texture = 0
+ framebuffer = 0
+ hardwareBuffer = null
+ announcedReady = false
+ }
+
+ companion object {
+ /** Only meaningful for the legacy GL renderer — see this class's kdoc. */
+ fun supportsRenderer(renderer: Any?): Boolean = renderer is GLRenderer
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/DirectVulkanBridge.kt b/app/src/main/java/app/gamenative/ui/screen/xr/DirectVulkanBridge.kt
new file mode 100644
index 0000000000..fdb76cb260
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/screen/xr/DirectVulkanBridge.kt
@@ -0,0 +1,38 @@
+package app.gamenative.ui.screen.xr
+
+import com.winlator.renderer.VulkanXrFrameBridge
+import timber.log.Timber
+
+/**
+ * [VulkanXrFrameBridge] implementation for the default (dxvk/Vulkan) renderer path — unlike
+ * [DirectGLBridge], there's no buffer allocation or import to do here at all: VulkanRenderer
+ * already turns the game's rendered frame into an AHardwareBuffer on every real frame (see
+ * VulkanRenderer.onUpdateWindowContentDirect's nativeUpdateWindowContentAHB call — this is the
+ * method PresentExtension actually routes real DXVK/Vulkan frame presents to, NOT
+ * onUpdateWindowContent), so this just forwards that same raw pointer to the native OpenXR
+ * session, which imports it the same way
+ * [DirectGLBridge]'s shared buffer is imported (see xr_immersive.cpp's
+ * importSharedBufferIfNeeded — it doesn't care which renderer produced the buffer).
+ *
+ * [onFrame] fires on essentially every frame a Vulkan-rendered window updates — there's no
+ * "only when some other condition is met" gate here (an earlier version assumed one tied to
+ * VulkanRenderer's "zero-copy scanout" path, which turned out to be unreachable dead code —
+ * see VulkanXrFrameBridge's kdoc).
+ */
+class DirectVulkanBridge(
+ private val onFrame: (ahbPtr: Long, width: Int, height: Int) -> Unit,
+) : VulkanXrFrameBridge {
+ private var announced = false
+
+ override fun onScanoutBuffer(ahbPtr: Long, width: Int, height: Int) {
+ if (!announced) {
+ announced = true
+ Timber.i(
+ "Immersive: VulkanRenderer AHardwareBuffer observed (%dx%d) — direct-render path active, PixelCopy of the game layer stopped",
+ width,
+ height,
+ )
+ }
+ onFrame(ahbPtr, width, height)
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveControls.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveControls.kt
new file mode 100644
index 0000000000..a83eaefbee
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveControls.kt
@@ -0,0 +1,44 @@
+package app.gamenative.ui.screen.xr
+
+/**
+ * State + callbacks for the "Immersif" quick-menu tab (see QuickMenu.kt). Passed into
+ * [app.gamenative.ui.screen.xserver.XServerScreen] only when running inside
+ * [ImmersiveXrActivity] — its presence is what makes the tab show up at all.
+ */
+data class ImmersiveControls(
+ val passthroughEnabled: Boolean,
+ val onPassthroughToggle: (Boolean) -> Unit,
+ // No slider-based position/size controls here anymore, by explicit request — the
+ // trigger-driven grab handles (ImmersiveXrActivity's own quadDistance/quadHorizontal/
+ // quadVertical/quadScale fields, moved via tryStartPointerGrab/updateActiveGrab) are the ONLY
+ // way to move/resize the quad now, not an alternative alongside a slider-based one.
+ // Vulkan's zero-copy direct-render path (better performance) only engages when no screen
+ // effect/filter/brightness/contrast/gamma adjustment is active — null when the renderer isn't
+ // VulkanRenderer at all (nothing to reset), so the button only shows when it's actually
+ // relevant and possible.
+ val directRenderBlockedByEffects: Boolean? = null,
+ val onResetScreenEffects: () -> Unit = {},
+ // Shows/enables the resize+move handles (drawn just inside the game content's own edges,
+ // not in an outside margin — see ImmersiveXrActivity's resizeHandlesEnabled kdoc for why:
+ // an always-present margin band cost a real chunk of the direct-render path's fixed
+ // swapchain pixel budget, visible as pixelation).
+ val resizeModeEnabled: Boolean = false,
+ val onResizeModeToggle: (Boolean) -> Unit = {},
+) {
+ companion object {
+ const val MIN_DISTANCE = 1.0f
+ const val MAX_DISTANCE = 5.0f
+ const val DEFAULT_DISTANCE = 2.0f
+
+ const val MIN_OFFSET = -2.0f
+ const val MAX_OFFSET = 2.0f
+ const val DEFAULT_OFFSET = 0.0f
+
+ const val MIN_SCALE = 0.5f
+ const val MAX_SCALE = 3.0f
+ const val DEFAULT_SCALE = 1.0f
+
+ const val BASE_WIDTH_METERS = 1.6f
+ const val BASE_HEIGHT_METERS = 0.9f
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt
new file mode 100644
index 0000000000..c849faab76
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt
@@ -0,0 +1,1768 @@
+package app.gamenative.ui.screen.xr
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.os.Bundle
+import android.os.Handler
+import android.os.HandlerThread
+import android.view.PixelCopy
+import android.view.SurfaceView
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.SportsEsports
+import androidx.compose.material.icons.filled.TouchApp
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.delay
+import app.gamenative.PluviaApp
+import app.gamenative.R
+import app.gamenative.service.SteamService
+import app.gamenative.ui.model.MainViewModel
+import app.gamenative.ui.screen.xserver.XServerScreen
+import app.gamenative.ui.theme.PluviaTheme
+import app.gamenative.utils.ContainerUtils
+import com.winlator.container.Container
+import com.winlator.core.AppUtils
+import com.winlator.renderer.GLRenderer
+import com.winlator.winhandler.WinHandler
+import dagger.hilt.android.AndroidEntryPoint
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.concurrent.thread
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+/**
+ * Dedicated entry point for launching a game directly in Meta Quest immersive mode.
+ *
+ * Unlike the normal Play flow (which navigates within [app.gamenative.MainActivity]'s
+ * nav-graph), this always boots straight to the configured executable
+ * (bootToContainer = false) — there is no "open the Wine desktop" variant here.
+ *
+ * Hosts the same [XServerScreen] composable used by the normal flow (bootstrap, Wine/Box64
+ * launch, on-screen controls, quick menu, etc. are all reused as-is) and layers a native
+ * OpenXR session on top for the actual immersive presentation + Touch controller input —
+ * see app/src/main/cpp/xrimmersive. The game's actual rendered frame is captured off the
+ * existing on-screen [PluviaApp.xServerView] via [PixelCopy] (works for both the Vulkan and
+ * legacy GL renderer, no changes to GLRenderer/XServerView needed) and handed to the native
+ * module, which uploads it into the OpenXR quad layer every frame — this is not a placeholder,
+ * it shows the actual running game.
+ */
+@AndroidEntryPoint
+class ImmersiveXrActivity : androidx.activity.ComponentActivity() {
+
+ companion object {
+ private const val EXTRA_APP_ID = "app_id"
+ private const val EXTRA_IS_OFFLINE = "is_offline"
+ private const val POLL_INTERVAL_MS = 11L // ~90Hz, matches the Quest's native refresh rate
+ // While the game window isn't up yet. Kept well above a tight spin: PixelCopy on a
+ // Surface that's mid-attach can crash *natively* (confirmed via tombstone — see
+ // scheduleNextCapture's isValid() comment) in a way no try/catch here can stop, so
+ // fewer attempts during that startup window means less exposure to the race, not just
+ // less log spam.
+ private const val CAPTURE_RETRY_DELAY_MS = 200L
+ private const val OVERLAY_REFRESH_INTERVAL_MS = 33L // ~30fps — plenty for a menu/HUD,
+ // and walking the whole Compose tree at full game-frame rate is what caused the crash.
+
+ // Horizon OS hands this Activity a volumetric panel surface at ~4128x2208 physical px but
+ // only reports density=1.25 (adb: `wm density` -> 200), i.e. a ~3300x1770dp logical canvas.
+ // QuickMenu/XServerScreen were laid out assuming a normal phone/tablet-sized dp canvas, so
+ // at that density every fixed-dp element (rail icons, text) renders as a tiny sliver pinned
+ // to one corner. Overriding density here forces a sane logical resolution instead.
+ private const val IMMERSIVE_UI_DENSITY = 2.5f
+
+ // Left-stick-as-dpad tuning for menu navigation (see lastMenuDpadKeyCode).
+ private const val MENU_DPAD_AXIS_THRESHOLD = 0.5f
+ private const val MENU_DPAD_INITIAL_DELAY_MS = 400L
+ private const val MENU_DPAD_REPEAT_DELAY_MS = 150L
+
+ // XR pointer-mode grab handles, in meters (same space as the quad transform).
+ //
+ // No margin: the game's own displayed size is IDENTICAL in every controller mode, and
+ // never grows for a margin band either — an earlier version grew the quad by a fixed
+ // margin on every side to give handles somewhere to live outside the game, but that
+ // margin came out of the direct-render path's fixed 1280x720 swapchain pixel budget
+ // (kSwapchainWidth/Height in xr_immersive.cpp) — a real, measured ~45% pixel-area loss at
+ // default scale, visible as pixelation. Handles now live INSIDE the content instead (see
+ // drawPointerCursors/nearGrabZone), only while resizeHandlesEnabled is on.
+ //
+ // Small visible gap between the game content's own edge and the drawn handle — sitting
+ // flush against the game's pixels reads as "stuck to the screen" rather than a floating
+ // system-style indicator.
+ private const val POINTER_INDICATOR_GAP_METERS = 0.02f
+ // Small round handles (corners + top/bottom edge midpoints), not long bars or crosses —
+ // closer to how actual VR headset system UI marks a grabbable edge: a small, elegant,
+ // consistent marker, not a shape that dominates the edge it's next to.
+ private const val POINTER_HANDLE_RADIUS_METERS = 0.035f
+ // Idle stroke is 2x the original 3.5f base, hover/grab is 3x — a thin line staying thin
+ // was easy to lose track of exactly when a hand was actually aiming at it.
+ private const val POINTER_HANDLE_STROKE_WIDTH_IDLE_PX = 7.0f
+ private const val POINTER_HANDLE_STROKE_WIDTH_ACTIVE_PX = 10.5f
+ // Shrunk from an earlier 0.16f — that radius, sitting right next to the quick menu's own
+ // buttons now that handles are drawn inside the content, was catching the pointer well
+ // before it was actually on a handle and blocking clicks on whatever's underneath. Small
+ // enough now that it takes deliberately aiming at the handle itself, not just its general
+ // neighborhood.
+ private const val POINTER_CORNER_RADIUS_METERS = 0.06f
+ private const val POINTER_BAR_HALF_WIDTH_METERS = 0.08f
+ private const val POINTER_BAR_RADIUS_METERS = 0.06f
+ // Hysteresis, not a single threshold: without it, an analog trigger resting near the
+ // threshold (hand tremor holding it "about half pulled") flaps press/release dozens of
+ // times a second — logs showed a grab restarting every ~250-300ms while the user held
+ // still, which is exactly that. Needs a firm pull past PRESS to start, only lets go past
+ // the much lower RELEASE.
+ private const val POINTER_GRAB_PRESS_THRESHOLD = 0.65f
+ private const val POINTER_GRAB_RELEASE_THRESHOLD = 0.3f
+ private const val MIN_CAPTURE_INTERVAL_MS = 11L // ~90fps cap, matches the HMD's own fixed
+ // compositor rate — capturing/uploading faster than the display can show is pure waste,
+ // competing with the game's own rendering for the same GPU for no visible benefit.
+
+ // Container extras — persist the "Immersif" quick-menu tab's settings per game.
+ private const val EXTRA_QUAD_DISTANCE = "immersiveQuadDistance"
+ private const val EXTRA_QUAD_SCALE = "immersiveQuadScale"
+ private const val EXTRA_PASSTHROUGH_ENABLED = "immersivePassthroughEnabled"
+
+ fun start(context: Context, appId: String, isOffline: Boolean) {
+ val intent = Intent(context, ImmersiveXrActivity::class.java).apply {
+ putExtra(EXTRA_APP_ID, appId)
+ putExtra(EXTRA_IS_OFFLINE, isOffline)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+ }
+
+ private val viewModel: MainViewModel by viewModels()
+
+ @Volatile
+ private var backAction: (() -> Unit)? = null
+
+ // Dedicated open/close toggle for the long-press-Start gesture — see XServerScreen's
+ // toggleQuickMenu kdoc for why this is used instead of backAction/gameBack.
+ @Volatile
+ private var quickMenuToggle: (() -> Unit)? = null
+
+ // See QuickMenu's registerFocusManager kdoc — driving Compose focus directly, bypassing
+ // Activity.dispatchKeyEvent() for arrow-key navigation (confirmed via logging that dispatched
+ // synthetic dpad KeyEvents never reach Compose's key dispatch in this Activity at all).
+ @Volatile
+ private var quickMenuFocusManager: androidx.compose.ui.focus.FocusManager? = null
+
+ // Same bypass as quickMenuFocusManager, for LB/RB tab-cycling — see QuickMenu's
+ // registerCycleTab kdoc.
+ @Volatile
+ private var quickMenuCycleTab: ((Boolean) -> Unit)? = null
+
+ // Same bypass again, for a locked QuickMenuAdjustmentRow's left/right drag — see QuickMenu's
+ // registerAdjustmentControl kdoc. Invoking this returns (onDecrease, onIncrease) for whichever
+ // row is currently focused+lock-toggled, or null if none is.
+ @Volatile
+ private var quickMenuAdjustmentControl: (() -> Pair<() -> Unit, () -> Unit>?)? = null
+
+ // Same bypass again, for LEFT specifically — see QuickMenu's registerFocusTabRail kdoc.
+ // FocusManager.moveFocus(Left) reaching the tab rail from content turned out to depend on the
+ // focused row's vertical position lining up with a rail icon (confirmed by comparing two
+ // sessions' logs: identical code, one where Left worked and one where it didn't, differing
+ // only in which row happened to be focused) — this calls requestFocus() on the CURRENTLY
+ // SELECTED tab's own rail button directly instead, which doesn't depend on that.
+ @Volatile
+ private var quickMenuFocusTabRail: (() -> Unit)? = null
+
+ // Same bypass again, for a plain radio/toggle row (e.g. ScreenEffectsPanel's scaling-mode
+ // rows) whose default DPAD_CENTER/A click doesn't reliably fire here — see QuickMenu's
+ // registerFocusedActivate kdoc. Invoking this returns whichever row's onSelect is currently
+ // focused, or null if none is.
+ @Volatile
+ private var quickMenuFocusedActivate: (() -> (() -> Unit)?)? = null
+
+ // Pushes the Menu/Start button's physical-hold state (native flags[2]) into QuickMenu, which
+ // uses it to suppress the real Android KeyEvents (DPAD_CENTER/BUTTON_A/BUTTON_START) that
+ // Horizon OS's own gamepad input dispatch generates while that button is held — a completely
+ // separate path from this Activity's native OpenXR polling, immune to every other suppression
+ // here.
+ @Volatile
+ private var quickMenuSetStartHeld: ((Boolean) -> Unit)? = null
+ private var lastPushedStartHeld = false
+
+ private var xrSessionHandle: Long = 0L
+ private var currentAppId: String? = null
+
+ private var pollingThread: Thread? = null
+ private val pollingActive = AtomicBoolean(false)
+ private var cachedWinHandler: WinHandler? = null
+
+ // Tracks Surface readiness via the actual SurfaceHolder lifecycle callbacks rather than
+ // polling isValid() — confirmed by testing (two native tombstones so far, same signature:
+ // SIGSEGV inside PixelCopy.request()'s own ANativeWindow_acquire) that isValid() can still
+ // race against the Surface being attached, since it's a point-in-time check with no ordering
+ // guarantee relative to the native call that follows it. surfaceCreated/surfaceDestroyed are
+ // real lifecycle events for this exact Surface, so gating on them is a much narrower window,
+ // even if not a mathematically airtight guarantee against every possible interleaving.
+ @Volatile
+ private var surfaceReady = false
+ private var surfaceCallbackAttachedTo: SurfaceView? = null
+ private val surfaceReadyCallback = object : android.view.SurfaceHolder.Callback {
+ override fun surfaceCreated(holder: android.view.SurfaceHolder) {
+ surfaceReady = true
+ }
+ override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, width: Int, height: Int) {}
+ override fun surfaceDestroyed(holder: android.view.SurfaceHolder) {
+ surfaceReady = false
+ }
+ }
+ private var cachedBridge: XrGamepadBridge? = null
+
+ private var captureThread: HandlerThread? = null
+ private var captureHandler: Handler? = null
+ private val captureActive = AtomicBoolean(false)
+ // Three bitmaps for the two decoupled loops (see startFrameCaptureLoop's kdoc):
+ // - gameCaptureBitmap: written by PixelCopy, at full game-frame rate.
+ // - overlayLayerBitmap: written by a real View.draw(), at a much slower throttled rate —
+ // guarded by overlayLock since it's read by the other (faster, background-thread) loop.
+ // - finalFrameBitmap: the actual composite of the two, uploaded to the native module.
+ private var gameCaptureBitmap: Bitmap? = null
+ private val overlayLock = Any()
+ private var overlayLayerBitmap: Bitmap? = null
+ private var finalFrameBitmap: Bitmap? = null
+ private var overlayCaptureLogCounter = 0
+ private var pointerGripHeldLogCounter = 0
+
+ // Direct GPU-render path — GLRenderer via DirectGLBridge/GLHardwareBufferImporter, or
+ // VulkanRenderer (the default for most real games/dxvk) via DirectVulkanBridge reusing its
+ // own per-frame AHardwareBuffer (see VulkanXrFrameBridge's kdoc for why this isn't actually
+ // tied to VulkanRenderer's own "zero-copy scanout" path, despite the name). Both are attached
+ // opportunistically and are no-ops
+ // until (and unless) their renderer type is actually found. directRenderActive only flips
+ // true once a real frame has actually flowed through — for GLRenderer that's guaranteed on
+ // the next frame once attached, but VulkanRenderer's scanout is conditional (disabled while
+ // screen-effect filters force the compositor path), so scheduleNextCapture keeps PixelCopy
+ // running as a fallback until this is confirmed true, not just whenever a bridge is attached.
+ // Known limitation: if scanout later deactivates mid-session (e.g. an effect toggled on),
+ // this does not currently fall back to PixelCopy again — out of scope for this pass.
+ private var directGLBridge: DirectGLBridge? = null
+ private var directVulkanBridge: DirectVulkanBridge? = null
+ @Volatile
+ private var directRenderActive = false
+ private var directRenderProbeLogged = false
+
+ // Menu navigation state: while quickMenuVisible, the left stick + A/B are translated into
+ // Android KeyEvents (dispatchKeyEvent) instead of being fed to Wine, since QuickMenu is
+ // regular Compose UI navigated via focusable()/FocusRequester like any Android TV-style
+ // screen — it never sees anything from XrGamepadBridge (that only writes to Wine's shared
+ // memory gamepad buffer), which is why the controller couldn't drive it at all before.
+ private var lastMenuDpadKeyCode: Int? = null
+ private var lastMenuDpadHeldSince = 0L
+ private var lastMenuDpadEventTime = 0L
+ private var lastMenuButtonAPressed = false
+ private var lastMenuButtonBPressed = false
+ private var lastMenuButtonLBPressed = false
+ private var lastMenuButtonRBPressed = false
+
+ // XR pointer mode: toggled by double-clicking either thumbstick (see native pointerModeToggled).
+ // Direct manipulation of the quad's transform via the controllers, like grabbing a flat
+ // panel window's corner/edge on Horizon OS — grab a corner to resize, grab the top/bottom
+ // edge to reposition. Independent of quickMenuVisible/isOverlayPaused: usable any time,
+ // same as a system window.
+ // mutableStateOf rather than a plain @Volatile var so the mode-change indicator (Compose)
+ // can react to it — writes from the polling thread are safe, Compose's snapshot system
+ // handles cross-thread visibility on its own. Reading it from plain (non-@Composable) logic
+ // code elsewhere in this class works exactly the same as a normal property.
+ private var xrPointerModeActive by mutableStateOf(false)
+ // Toggled by a dedicated quick-menu button (Immersive tab), NOT the pointer-mode chord — an
+ // earlier version made resize/move handles always available (given a margin band to live in)
+ // in pointer mode, which cost a real, measured chunk of the direct-render path's fixed
+ // swapchain pixel budget (~45% of the pixel area at default scale) just to have somewhere for
+ // handles to sit outside the game. Handles now live INSIDE the content instead (see
+ // drawPointerCursors/nearGrabZone) and only exist at all — visible or grabbable — while this
+ // is on, so the game gets the full pixel budget the rest of the time. Same mutableStateOf
+ // cross-thread pattern as xrPointerModeActive above.
+ private var resizeHandlesEnabled by mutableStateOf(false)
+ // Either the grip or the index trigger — see handlePointerMode's kdoc for why these were
+ // unified into one "action" signal per hand instead of assigning grab and click to specific
+ // buttons. @Volatile since the quick menu's resize-handles toggle (UI thread) now also writes
+ // these directly, alongside the polling thread — previously only the polling thread ever
+ // touched them, so plain vars were safe; a raw cross-thread write without this is the exact
+ // class of visibility bug already found and fixed once this session for xrFrameBridge fields.
+ @Volatile private var lastLeftActionPressed = false
+ @Volatile private var lastRightActionPressed = false
+ @Volatile private var pointerGrabHand: Int? = null // 0 = left, 1 = right
+ private var pointerGrabIsResize = false
+ private val pointerGrabStartHandPos = FloatArray(3)
+ private var pointerGrabStartDistance = 0f
+ private var pointerGrabStartHorizontal = 0f
+ private var pointerGrabStartVertical = 0f
+ private var pointerGrabStartScale = 0f
+
+ // Where each hand's ray currently hits the quad plane, in quad-local meters — read by
+ // compositeAndSubmitFrame (a different thread) to draw a visible cursor dot, since without
+ // ANY feedback the user has no way to tell where they're pointing (there's no true 3D laser
+ // — that needs native GL line rendering, a bigger lift; a 2D dot drawn directly onto the
+ // composited frame is a much cheaper approximation that still answers "where am I aiming").
+ @Volatile private var pointerCursorLeftValid = false
+ @Volatile private var pointerCursorLeftX = 0f
+ @Volatile private var pointerCursorLeftY = 0f
+ @Volatile private var pointerCursorRightValid = false
+ @Volatile private var pointerCursorRightX = 0f
+ @Volatile private var pointerCursorRightY = 0f
+
+ private var pointerTouchDownTimeLeft = 0L
+ private var pointerTouchDownTimeRight = 0L
+
+ // Set by XServerScreen's onQuickMenuVisibilityChanged. SurfaceView content is composited on
+ // its own layer by the system — PixelCopy(Window) straight up cannot see it (shows a blank/
+ // gray hole instead), so the game must be captured via PixelCopy(SurfaceView) directly. The
+ // quick menu (and other overlay UI) is regular Compose content in the *other* layer, which
+ // PixelCopy(SurfaceView) can't see either — so this flag switches capture source instead of
+ // trying to composite both, since the game is paused behind the menu anyway.
+ @Volatile
+ private var quickMenuVisible = false
+
+ // Both only ever read/written from the polling thread (startControllerPollingLoop) — see its
+ // own kdoc for why these exist: suppressing a button that's still held right as menu/pause
+ // handling hands control back to the game, so the same physical press that dismissed our
+ // menu doesn't also reach the game as a fresh press.
+ private var wasInMenuNavigationMode = false
+ private var buttonSuppressMaskUntilRelease = 0
+
+ // "Immersif" quick-menu tab state — loaded from the container's extras once available,
+ // applied to the native quad transform on every change, and persisted back.
+ private var quadDistance by mutableFloatStateOf(ImmersiveControls.DEFAULT_DISTANCE)
+ private var quadHorizontal by mutableFloatStateOf(ImmersiveControls.DEFAULT_OFFSET)
+ private var quadVertical by mutableFloatStateOf(ImmersiveControls.DEFAULT_OFFSET)
+ private var quadScale by mutableFloatStateOf(ImmersiveControls.DEFAULT_SCALE)
+ private var passthroughEnabled by mutableStateOf(false)
+ private var showControlsOnboarding by mutableStateOf(false)
+ private var cachedContainer: Container? = null
+ // Refreshed whenever the quick menu opens (see onQuickMenuVisibilityChanged) and right after
+ // the reset button runs — null when the renderer isn't VulkanRenderer at all (the "Immersif"
+ // tab's reset button/description only shows when this is non-null).
+ private var directRenderBlockedByEffects by mutableStateOf(null)
+
+ override fun attachBaseContext(newBase: Context) {
+ // Horizon OS hands this Activity a volumetric panel surface at ~4128x2208 physical px but
+ // only reports density=1.25 (adb: `wm density` -> 200), i.e. a ~3300x1770dp logical
+ // canvas. QuickMenu/XServerScreen's HUD (a plain View added via addView, not Compose —
+ // LocalDensity overrides in setContent wouldn't reach it) were both built assuming a
+ // normal phone/tablet-sized dp canvas, so at that density every fixed-dp element renders
+ // as a tiny sliver. Overriding density on the base Context fixes it for Compose *and*
+ // legacy Views uniformly, since everything in this Activity inherits from it.
+ val config = android.content.res.Configuration(newBase.resources.configuration)
+ config.densityDpi = (IMMERSIVE_UI_DENSITY * android.util.DisplayMetrics.DENSITY_DEFAULT).toInt()
+ super.attachBaseContext(newBase.createConfigurationContext(config))
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Timber.i(
+ "Immersive: density override applied -> %.3f (logical size %dx%d dp)",
+ resources.displayMetrics.density,
+ (resources.displayMetrics.widthPixels / resources.displayMetrics.density).toInt(),
+ (resources.displayMetrics.heightPixels / resources.displayMetrics.density).toInt(),
+ )
+
+ val appId = intent.getStringExtra(EXTRA_APP_ID)
+ val isOffline = intent.getBooleanExtra(EXTRA_IS_OFFLINE, false)
+ if (appId == null) {
+ finish()
+ return
+ }
+ currentAppId = appId
+
+ AppUtils.keepScreenOn(this)
+ loadImmersiveSettings(appId)
+
+ setContent {
+ PluviaTheme {
+ val context = LocalContext.current
+
+ androidx.activity.compose.BackHandler(enabled = backAction != null) {
+ backAction?.invoke()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ XServerScreen(
+ appId = appId,
+ bootToContainer = false,
+ isOffline = isOffline,
+ registerBackAction = { cb -> backAction = cb },
+ registerQuickMenuToggle = { toggle -> quickMenuToggle = toggle },
+ registerQuickMenuStartHeld = { setter -> quickMenuSetStartHeld = setter },
+ registerQuickMenuFocusManager = { fm -> quickMenuFocusManager = fm },
+ registerQuickMenuCycleTab = { cycle -> quickMenuCycleTab = cycle },
+ registerQuickMenuAdjustmentControl = { control -> quickMenuAdjustmentControl = control },
+ registerQuickMenuFocusTabRail = { action -> quickMenuFocusTabRail = action },
+ registerQuickMenuFocusedActivate = { getter -> quickMenuFocusedActivate = getter },
+ navigateBack = { finish() },
+ onExit = { onComplete ->
+ viewModel.exitSteamApp(context, appId) {
+ onComplete?.invoke()
+ finish()
+ }
+ },
+ onWindowMapped = { ctx, window ->
+ viewModel.onWindowMapped(ctx, window, appId)
+ showControlsOnboarding = true
+ },
+ onGameLaunchError = { error ->
+ viewModel.onGameLaunchError(error)
+ finish()
+ },
+ onQuickMenuVisibilityChanged = { visible ->
+ Timber.i("Immersive: quick menu visibility changed to %b", visible)
+ quickMenuVisible = visible
+ if (visible) {
+ directRenderBlockedByEffects = (PluviaApp.xServerView?.renderer as? com.winlator.renderer.VulkanRenderer)
+ ?.isEffectsRequireCompositor()
+ }
+ },
+ immersiveControls = ImmersiveControls(
+ passthroughEnabled = passthroughEnabled,
+ onPassthroughToggle = { enabled ->
+ passthroughEnabled = enabled
+ if (xrSessionHandle != 0L) {
+ XrNative.nativeSetPassthroughEnabled(xrSessionHandle, enabled)
+ }
+ persistImmersiveSettings(appId)
+ },
+ directRenderBlockedByEffects = directRenderBlockedByEffects,
+ onResetScreenEffects = {
+ val vulkanRenderer = PluviaApp.xServerView?.renderer as? com.winlator.renderer.VulkanRenderer
+ vulkanRenderer?.resetScreenEffects()
+ directRenderBlockedByEffects = vulkanRenderer?.isEffectsRequireCompositor()
+ Timber.i("Immersive: screen effects reset from quick menu, blocked=%s", directRenderBlockedByEffects)
+ },
+ resizeModeEnabled = resizeHandlesEnabled,
+ onResizeModeToggle = { enabled ->
+ resizeHandlesEnabled = enabled
+ // Handles are only usable via the XR laser pointer (grabbing a
+ // corner/edge), not Xbox mode's stick+dpad navigation — switch modes
+ // together with the toggle instead of leaving the user to find the
+ // pointer-mode chord themselves, and always land back on Xbox mode
+ // when turning handles off (not whichever mode was active before).
+ xrPointerModeActive = enabled
+ lastLeftActionPressed = false
+ lastRightActionPressed = false
+ if (!enabled) {
+ pointerGrabHand = null
+ pointerCursorLeftValid = false
+ pointerCursorRightValid = false
+ }
+ Timber.i("Immersive: resize handles toggled %s from quick menu, pointer mode now %s", enabled, enabled)
+ },
+ ),
+ )
+
+ ImmersiveControlsOnboarding(
+ visible = showControlsOnboarding,
+ onDismiss = { showControlsOnboarding = false },
+ )
+ ImmersiveModeChangeIndicator(pointerModeActive = xrPointerModeActive)
+ }
+ }
+ }
+ }
+
+ private fun loadImmersiveSettings(appId: String) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ val container = try {
+ ContainerUtils.getContainer(this@ImmersiveXrActivity, appId)
+ } catch (t: Throwable) {
+ Timber.w(t, "Could not load container for immersive settings, using defaults")
+ return@launch
+ }
+ cachedContainer = container
+ quadDistance = container.getExtra(EXTRA_QUAD_DISTANCE, ImmersiveControls.DEFAULT_DISTANCE.toString())
+ .toFloatOrNull() ?: ImmersiveControls.DEFAULT_DISTANCE
+ // Always start facing the user, regardless of whatever orbit position was persisted
+ // from the previous session — only distance/scale carry over.
+ quadHorizontal = ImmersiveControls.DEFAULT_OFFSET
+ quadVertical = ImmersiveControls.DEFAULT_OFFSET
+ quadScale = container.getExtra(EXTRA_QUAD_SCALE, ImmersiveControls.DEFAULT_SCALE.toString())
+ .toFloatOrNull() ?: ImmersiveControls.DEFAULT_SCALE
+ passthroughEnabled = container.getExtra(EXTRA_PASSTHROUGH_ENABLED, "false").toBoolean()
+ applyQuadTransform()
+ if (xrSessionHandle != 0L) {
+ XrNative.nativeSetPassthroughEnabled(xrSessionHandle, passthroughEnabled)
+ }
+ }
+ }
+
+ private fun persistImmersiveSettings(appId: String) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ val container = cachedContainer ?: try {
+ ContainerUtils.getContainer(this@ImmersiveXrActivity, appId).also { cachedContainer = it }
+ } catch (t: Throwable) {
+ Timber.w(t, "Could not persist immersive settings")
+ return@launch
+ }
+ container.putExtra(EXTRA_QUAD_DISTANCE, quadDistance.toString())
+ container.putExtra(EXTRA_QUAD_SCALE, quadScale.toString())
+ container.putExtra(EXTRA_PASSTHROUGH_ENABLED, passthroughEnabled.toString())
+ container.saveData()
+ }
+ }
+
+ private fun applyQuadTransform() {
+ if (xrSessionHandle == 0L) return
+ // No margin — the quad is always exactly the game's own content size, identical in every
+ // controller mode. See compositeGameFrame's kdoc for why: an earlier version grew the
+ // quad by a fixed margin band to give resize/move handles a place to live outside the
+ // game, which cost a real, measured chunk of the direct-render path's fixed swapchain
+ // pixel budget. contentScaleX/Y are always 1.0 now (no remap needed) — kept as real JNI
+ // parameters rather than removed so this stays a plain revert-to-1.0, not a signature
+ // change, should a margin ever come back.
+ XrNative.nativeSetQuadTransform(
+ xrSessionHandle,
+ quadHorizontal,
+ quadVertical,
+ -quadDistance,
+ ImmersiveControls.BASE_WIDTH_METERS * quadScale,
+ ImmersiveControls.BASE_HEIGHT_METERS * quadScale,
+ 1f,
+ 1f,
+ )
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // MainActivity is the only thing that normally toggles this flag (see its own
+ // onResume/onPause). Since this is a separate Activity, MainActivity.onPause() fires
+ // when we're launched (setting it false) and nothing ever set it back — XServerScreen's
+ // post-setup check (`if (!PluviaApp.isActivityInForeground && !neverSuspend)`) then
+ // immediately re-paused the freshly-launched game, which is why immersive launches
+ // showed a black screen: the game process was running but paused right away.
+ PluviaApp.isActivityInForeground = true
+ if (SteamService.keepAlive && PluviaApp.hasValidSuspendPolicyState() && PluviaApp.xEnvironment != null) {
+ when {
+ PluviaApp.isNeverSuspendMode() -> Unit
+ PluviaApp.isOverlayPaused && PluviaApp.isManualSuspendMode() -> Unit
+ else -> PluviaApp.xEnvironment?.onResume()
+ }
+ }
+ startXrSessionIfNeeded()
+ }
+
+ override fun onPause() {
+ PluviaApp.isActivityInForeground = false
+ Timber.i(
+ "Immersive: onPause, isFinishing=%b isChangingConfigurations=%b",
+ isFinishing,
+ isChangingConfigurations,
+ )
+ if (isFinishing && !isChangingConfigurations) {
+ // Android guarantees the outgoing activity's onPause() completes before the
+ // incoming one's onResume() runs, but does NOT guarantee onDestroy() runs that
+ // early — it can (and did, per testing) land after MainActivity.onResume() has
+ // already read PluviaApp.xEnvironment/SteamService.keepAlive and decided to show
+ // "Launching..." again for a game that no longer has a window. Tearing down here
+ // instead — while we know for sure this pause means we're really exiting, not just
+ // briefly backgrounding for a system dialog — closes that race.
+ PluviaApp.shutdownEnvironment()
+ } else if (SteamService.keepAlive && PluviaApp.hasValidSuspendPolicyState() &&
+ PluviaApp.xEnvironment != null && !PluviaApp.isNeverSuspendMode()
+ ) {
+ PluviaApp.xEnvironment?.onPause()
+ }
+ super.onPause()
+ }
+
+ override fun onDestroy() {
+ stopXrSession()
+ // Safety net for onPause() not having caught it (e.g. the process/Activity was torn
+ // down some other way): every step here no-ops if shutdownEnvironment() already ran.
+ PluviaApp.shutdownEnvironment()
+ super.onDestroy()
+ }
+
+ private fun startXrSessionIfNeeded() {
+ if (xrSessionHandle != 0L) return
+ xrSessionHandle = try {
+ XrNative.nativeCreate(this)
+ } catch (t: Throwable) {
+ // libxrimmersive.so hasn't been built/bundled yet (see XrNative's kdoc) — degrade to
+ // a plain direct launch rather than crashing the whole immersive entry point.
+ Timber.w(t, "Native OpenXR module unavailable — immersive rendering/controller mapping disabled")
+ return
+ }
+
+ // Apply whatever settings are already loaded (may still be the defaults if
+ // loadImmersiveSettings()'s IO hasn't finished yet — it re-applies once it has).
+ applyQuadTransform()
+ XrNative.nativeSetPassthroughEnabled(xrSessionHandle, passthroughEnabled)
+
+ startControllerPollingLoop()
+ startFrameCaptureLoop()
+ }
+
+ private fun startControllerPollingLoop() {
+ pollingActive.set(true)
+ pollingThread = thread(name = "XrGamepadPoll") {
+ val buttons = IntArray(1)
+ val axes = FloatArray(6)
+ val handPoses = FloatArray(12)
+ val flags = BooleanArray(3)
+ while (pollingActive.get()) {
+ val winHandler = PluviaApp.xServerView?.getxServer()?.winHandler
+ if (winHandler != null) {
+ if (winHandler !== cachedWinHandler) {
+ cachedWinHandler = winHandler
+ cachedBridge = XrGamepadBridge(winHandler)
+ }
+ val quickMenuClicked = XrNative.nativePollSnapshot(xrSessionHandle, buttons, axes, handPoses, flags)
+ if (flags[2] != lastPushedStartHeld) {
+ lastPushedStartHeld = flags[2]
+ val setter = quickMenuSetStartHeld
+ if (setter != null) runOnUiThread { setter(lastPushedStartHeld) }
+ }
+ if (flags[1]) {
+ xrPointerModeActive = !xrPointerModeActive
+ Timber.i("Immersive: XR pointer mode %s", if (xrPointerModeActive) "enabled" else "disabled")
+ // The activation chord itself holds down the buttons driving BOTH grab
+ // and click — seeding these from this SAME poll's snapshot (still held
+ // from the chord) rather than forcing false avoids a spurious same-poll
+ // rising edge: an earlier version forced both to false here, which made
+ // handlePointerMode see a brand-new press on this exact poll and
+ // immediately act on whatever the hand happened to be aiming at mid-chord
+ // — logs confirmed this both fired phantom grab attempts right after every
+ // mode-enable, AND (for the click/trigger case) could dispatch a synthetic
+ // touch that landed on the quick menu's dismiss backdrop, closing it. The
+ // user now needs one clean release+press to start a grab/click, same as
+ // any other button — slightly less "instant" than the original intent,
+ // but no more phantom presses eating the user's first real one.
+ lastLeftActionPressed = (axes[4] > POINTER_GRAB_PRESS_THRESHOLD) ||
+ ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_LB)) != 0)
+ lastRightActionPressed = (axes[5] > POINTER_GRAB_PRESS_THRESHOLD) ||
+ ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_RB)) != 0)
+ if (!xrPointerModeActive) {
+ pointerGrabHand = null
+ pointerCursorLeftValid = false
+ pointerCursorRightValid = false
+ }
+ }
+ val inMenuMode = quickMenuVisible || PluviaApp.isOverlayPaused
+ if (wasInMenuNavigationMode && !inMenuMode) {
+ // Just exited quick-menu/pause handling — e.g. the same A press that just
+ // dismissed our own "paused" screen and resumed the game is very likely
+ // still physically held on THIS exact poll. Forwarding it immediately as
+ // a fresh gamepad press let the GAME's own in-game pause menu (a separate
+ // thing from our shell's pause state) read that same held button as a
+ // second, unwanted "confirm" — e.g. pressing A to unpause also activating
+ // whatever the game's own menu had focused (its Resume item). Snapshot
+ // whatever's held right now and mask it out of forwarding until released.
+ buttonSuppressMaskUntilRelease = buttons[0]
+ }
+ wasInMenuNavigationMode = inMenuMode
+ when {
+ xrPointerModeActive -> handlePointerMode(buttons[0], axes, handPoses, flags[0])
+ // While the Menu/Start button is physically held (flags[2]), skip menu
+ // navigation entirely — same hand that's holding it may still rest on/near
+ // the thumbstick, and residual leftX/leftY was being read as a genuine dpad
+ // direction the instant the menu opened (or closed), landing on whatever
+ // was focused — e.g. dragging an already-locked slider. Start should only
+ // open/close the menu, nothing else; this makes that true regardless of
+ // stray input during the hold, and applies symmetrically to both the
+ // opening AND the closing hold.
+ inMenuMode && !flags[2] -> handleMenuNavigation(buttons[0], axes)
+ inMenuMode -> Unit
+ else -> {
+ buttonSuppressMaskUntilRelease = buttonSuppressMaskUntilRelease and buttons[0]
+ cachedBridge?.applySnapshot(buttons[0] and buttonSuppressMaskUntilRelease.inv(), axes)
+ }
+ }
+ if (quickMenuClicked) {
+ Timber.i(
+ "Immersive: quick-menu chord fired, quickMenuToggle registered=%b buttons=0x%03x " +
+ "quickMenuVisible=%b isOverlayPaused=%b xrPointerModeActive=%b",
+ quickMenuToggle != null, buttons[0], quickMenuVisible, PluviaApp.isOverlayPaused, xrPointerModeActive,
+ )
+ runOnUiThread { quickMenuToggle?.invoke() }
+ }
+ }
+ Thread.sleep(POLL_INTERVAL_MS)
+ }
+ }
+ }
+
+ /**
+ * Direct manipulation of the quad transform via a hand's aim-pose ray, active only while
+ * [xrPointerModeActive]. Either the grip OR the index trigger works for BOTH grabbing a
+ * corner/edge handle and clicking a UI element — whichever the user presses is treated the
+ * same way. This used to require a SPECIFIC button for each action, and repeated,
+ * contradictory feedback about which physical button is "top" vs "bottom" on the controller
+ * never converged on a mapping that felt right — unifying them removes the ambiguity
+ * entirely instead of guessing a mapping again. Grab a corner (within
+ * [POINTER_CORNER_RADIUS_METERS]) to resize; grab the top/bottom edge (within
+ * [POINTER_BAR_RADIUS_METERS]) to reposition. Deltas are computed relative to the hand's
+ * position when the grab started, not incrementally, so there's no drift from small
+ * per-frame rounding.
+ */
+ private fun handlePointerMode(buttons: Int, axes: FloatArray, handPoses: FloatArray, posesValid: Boolean) {
+ val leftGripPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_LB)) != 0
+ val rightGripPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_RB)) != 0
+ // Trigger uses hysteresis (two thresholds, see their own kdoc) since it's an analog value
+ // with no clean digital edge; the grip is already a plain digital button.
+ val leftTriggerPressed = axes[4] > (if (lastLeftActionPressed) POINTER_GRAB_RELEASE_THRESHOLD else POINTER_GRAB_PRESS_THRESHOLD)
+ val rightTriggerPressed = axes[5] > (if (lastRightActionPressed) POINTER_GRAB_RELEASE_THRESHOLD else POINTER_GRAB_PRESS_THRESHOLD)
+ val leftActionPressed = leftGripPressed || leftTriggerPressed
+ val rightActionPressed = rightGripPressed || rightTriggerPressed
+ val leftReleasedNow = !leftActionPressed && lastLeftActionPressed
+ val rightReleasedNow = !rightActionPressed && lastRightActionPressed
+ val leftPressedNow = leftActionPressed && !lastLeftActionPressed
+ val rightPressedNow = rightActionPressed && !lastRightActionPressed
+ if ((leftActionPressed && !leftPressedNow && pointerGrabHand != 0) ||
+ (rightActionPressed && !rightPressedNow && pointerGrabHand != 1)
+ ) {
+ // The action button is physically down but we didn't see a rising edge for it — means
+ // it was already held (e.g. still down from the activation chord) when this hand
+ // started being read as pressed. Logged (rate-limited) so this is distinguishable
+ // from "we tried and missed the zone".
+ pointerGripHeldLogCounter++
+ if (pointerGripHeldLogCounter % 45 == 0) {
+ Timber.i(
+ "Immersive: action button held without a fresh edge — left=%b(pressedNow=%b) right=%b(pressedNow=%b) grabbing=%s",
+ leftActionPressed, leftPressedNow, rightActionPressed, rightPressedNow, pointerGrabHand,
+ )
+ }
+ }
+ lastLeftActionPressed = leftActionPressed
+ lastRightActionPressed = rightActionPressed
+
+ if (!posesValid) {
+ pointerCursorLeftValid = false
+ pointerCursorRightValid = false
+ return
+ }
+
+ val leftHit = rayPlaneHit(handPoses, 0, quadHorizontal, quadVertical, quadDistance)
+ pointerCursorLeftValid = leftHit != null
+ if (leftHit != null) {
+ pointerCursorLeftX = leftHit[0]
+ pointerCursorLeftY = leftHit[1]
+ }
+ val rightHit = rayPlaneHit(handPoses, 1, quadHorizontal, quadVertical, quadDistance)
+ pointerCursorRightValid = rightHit != null
+ if (rightHit != null) {
+ pointerCursorRightX = rightHit[0]
+ pointerCursorRightY = rightHit[1]
+ }
+
+ handHandAction(0, leftPressedNow, leftReleasedNow, leftActionPressed, leftHit, handPoses)
+ handHandAction(1, rightPressedNow, rightReleasedNow, rightActionPressed, rightHit, handPoses)
+ }
+
+ /** One hand's share of handlePointerMode — a fresh press either starts a grab (if aimed at a
+ * handle) OR dispatches a click, never both for the same press (tryStartPointerGrab's return
+ * value decides which, so a grab-starting press can't also fire a spurious click underneath
+ * it — merging grab and click onto the same button made that a real risk that separate
+ * buttons never had). */
+ private fun handHandAction(
+ hand: Int,
+ pressedNow: Boolean,
+ releasedNow: Boolean,
+ held: Boolean,
+ hit: FloatArray?,
+ handPoses: FloatArray,
+ ) {
+ if (pointerGrabHand == hand) {
+ if (releasedNow) {
+ pointerGrabHand = null
+ currentAppId?.let { persistImmersiveSettings(it) }
+ } else {
+ updateActiveGrab(handPoses)
+ }
+ return
+ }
+ when {
+ pressedNow -> {
+ if (!tryStartPointerGrab(hand, handPoses)) {
+ dispatchPointerTouch(hand, android.view.MotionEvent.ACTION_DOWN, hit)
+ }
+ }
+ releasedNow -> dispatchPointerTouch(hand, android.view.MotionEvent.ACTION_UP, hit)
+ held -> dispatchPointerTouch(hand, android.view.MotionEvent.ACTION_MOVE, hit)
+ }
+ }
+
+ /** Converts a hand's quad-local hit point (meters) into content-view pixel coordinates and
+ * dispatches a synthetic touch event there — lets the XR pointer act as a real touch/mouse
+ * input for whatever Compose UI is on screen (menu buttons, adjustment +/-, the Resume
+ * button...), the same way tapping a Quest system panel with a controller ray does. */
+ private fun dispatchPointerTouch(hand: Int, action: Int, hit: FloatArray?) {
+ if (hit == null) return
+ val halfWidth = (ImmersiveControls.BASE_WIDTH_METERS * quadScale) / 2f
+ val halfHeight = (ImmersiveControls.BASE_HEIGHT_METERS * quadScale) / 2f
+ if (halfWidth <= 0f || halfHeight <= 0f) return
+ // hit is already quad-LOCAL meters (rayPlaneHit projects onto the quad's own basis) — no
+ // further conversion needed.
+ val localX = hit[0]
+ val localY = hit[1]
+
+ val now = android.os.SystemClock.uptimeMillis()
+ if (action == android.view.MotionEvent.ACTION_DOWN) {
+ if (hand == 0) pointerTouchDownTimeLeft = now else pointerTouchDownTimeRight = now
+ }
+ val downTime = (if (hand == 0) pointerTouchDownTimeLeft else pointerTouchDownTimeRight)
+ .takeIf { it > 0L } ?: now
+
+ runOnUiThread {
+ val contentView = findViewById(android.R.id.content)
+ val width = contentView.width
+ val height = contentView.height
+ if (width <= 0 || height <= 0) return@runOnUiThread
+
+ val px = ((localX + halfWidth) / (2f * halfWidth)) * width
+ val py = ((halfHeight - localY) / (2f * halfHeight)) * height
+ if (px < 0f || px > width || py < 0f || py > height) return@runOnUiThread
+
+ val event = android.view.MotionEvent.obtain(downTime, now, action, px, py, 0)
+ // obtain() leaves source as SOURCE_UNKNOWN — Compose's pointer input system
+ // classifies events by source (touch/mouse/stylus) and silently ignores ones it
+ // can't classify, which is almost certainly why this dispatch had no visible effect.
+ event.source = android.view.InputDevice.SOURCE_TOUCHSCREEN
+ try {
+ dispatchTouchEvent(event)
+ } finally {
+ event.recycle()
+ }
+ }
+ }
+
+ private val pointerCursorPaint = android.graphics.Paint().apply {
+ isAntiAlias = true
+ style = android.graphics.Paint.Style.FILL
+ }
+
+ /** Draws a simple 2D dot at each hand's ray/quad hit point — see pointerCursor* fields. */
+ private fun toPixel(localX: Float, localY: Float, halfWidth: Float, halfHeight: Float, width: Int, height: Int): FloatArray {
+ val px = ((localX + halfWidth) / (2f * halfWidth)) * width
+ val py = ((halfHeight - localY) / (2f * halfHeight)) * height
+ return floatArrayOf(px, py)
+ }
+
+ /** Draws the per-hand aiming cursor always (while in pointer mode), and the 6 grab handles
+ * (4 corners + top/bottom edge) only while [resizeHandlesEnabled] is on — that toggle, and
+ * drawing handles INSIDE the content instead of in an outside margin band, replaced an
+ * earlier design that grew the quad by a fixed margin on every side purely to give handles
+ * somewhere to live outside the game: that cost a real, measured chunk of the direct-render
+ * path's fixed swapchain pixel budget (~45% of the pixel area at default scale), which
+ * showed up as pixelation the user explicitly rejected as a tradeoff. [width]/[height] are
+ * now simply the content's own canvas — no separate "outer" canvas exists anymore. */
+ private fun drawPointerCursors(canvas: android.graphics.Canvas, width: Int, height: Int) {
+ val contentHalfWidth = (ImmersiveControls.BASE_WIDTH_METERS * quadScale) / 2f
+ val contentHalfHeight = (ImmersiveControls.BASE_HEIGHT_METERS * quadScale) / 2f
+ if (contentHalfWidth <= 0f || contentHalfHeight <= 0f) return
+
+ fun toContentPixel(localX: Float, localY: Float) = toPixel(localX, localY, contentHalfWidth, contentHalfHeight, width, height)
+
+ // Cursor dot per hand — still a 2D approximation baked into this same texture, not a true
+ // 3D laser (that needs native geometry rendering — a bigger follow-up, see Nightfall's
+ // own capsule-mesh laser for the target design). Drawn regardless of resizeHandlesEnabled
+ // — this is the aiming feedback for clicking the quick menu, independent of whether
+ // resize/move handles are also on right now.
+ fun drawCursor(valid: Boolean, localX: Float, localY: Float, isGrabbing: Boolean) {
+ if (!valid) return
+ val (rawPx, rawPy) = toContentPixel(localX, localY)
+ val px = rawPx.coerceIn(0f, width.toFloat())
+ val py = rawPy.coerceIn(0f, height.toFloat())
+ val (nearCorner, nearEdgeBar) = nearGrabZone(localX, localY, contentHalfWidth, contentHalfHeight)
+ pointerCursorPaint.style = if (nearCorner || nearEdgeBar || isGrabbing) {
+ android.graphics.Paint.Style.FILL
+ } else {
+ android.graphics.Paint.Style.STROKE
+ }
+ pointerCursorPaint.strokeWidth = 4f
+ pointerCursorPaint.color = android.graphics.Color.WHITE
+ canvas.drawCircle(px, py, if (isGrabbing) 16f * quadScale else 11f * quadScale, pointerCursorPaint)
+ }
+ drawCursor(pointerCursorLeftValid, pointerCursorLeftX, pointerCursorLeftY, pointerGrabHand == 0)
+ drawCursor(pointerCursorRightValid, pointerCursorRightX, pointerCursorRightY, pointerGrabHand == 1)
+
+ if (!resizeHandlesEnabled) return
+
+ val handleRadiusPx = POINTER_HANDLE_RADIUS_METERS * width / (2f * contentHalfWidth)
+
+ // Each valid hand's current quad-local hit point, paired with which hand it is (needed to
+ // check pointerGrabHand for the "grabbed" tier specifically, not just "hovering").
+ val hands = buildList {
+ if (pointerCursorLeftValid) add(Triple(pointerCursorLeftX, pointerCursorLeftY, 0))
+ if (pointerCursorRightValid) add(Triple(pointerCursorRightX, pointerCursorRightY, 1))
+ }
+
+ // (alpha, strokeWidth) together — a hand aiming AT a handle needs to be unmistakable, both
+ // more opaque AND visibly thicker, not just brighter (a thin line staying thin was easy to
+ // lose track of exactly when it mattered most: right as you're lining up a grab).
+ fun zoneStyle(signX: Int, signY: Int, isCorner: Boolean): Pair {
+ var hovering = false
+ var grabbing = false
+ for ((hLocalX, hLocalY, hand) in hands) {
+ val (nearCorner, nearEdgeBar) = nearGrabZone(hLocalX, hLocalY, contentHalfWidth, contentHalfHeight)
+ val matchesThisZone = if (isCorner) {
+ nearCorner && kotlin.math.sign(hLocalX).toInt() == signX && kotlin.math.sign(hLocalY).toInt() == signY
+ } else {
+ nearEdgeBar && kotlin.math.sign(hLocalY).toInt() == signY
+ }
+ if (matchesThisZone) {
+ hovering = true
+ if (pointerGrabHand == hand) grabbing = true
+ }
+ }
+ return when {
+ grabbing -> 180 to POINTER_HANDLE_STROKE_WIDTH_ACTIVE_PX // ~0.7 opacity, 3x the idle stroke
+ hovering -> 90 to POINTER_HANDLE_STROKE_WIDTH_ACTIVE_PX // ~0.35 opacity, 3x the idle stroke
+ else -> 20 to POINTER_HANDLE_STROKE_WIDTH_IDLE_PX // ~0.08 opacity, present but barely there
+ }
+ }
+
+ // Thin corner brackets (an "L" hugging each corner, arms pointing inward) and a thin
+ // pill for each edge handle — NOT filled circles. A filled disc covers far more pixels
+ // than a thin line at the same opacity, which is what made the circle version look like
+ // a solid "ball" even at low alpha; a thin stroke stays genuinely subtle at the same tier.
+ // Anchored INSIDE the content edge by POINTER_INDICATOR_GAP_METERS (the same gap that
+ // used to sit between the content edge and a handle drawn OUTSIDE it, in the margin band
+ // that no longer exists) instead of outside it.
+ val handlePaint = android.graphics.Paint().apply {
+ isAntiAlias = true
+ style = android.graphics.Paint.Style.STROKE
+ strokeWidth = POINTER_HANDLE_STROKE_WIDTH_IDLE_PX
+ strokeCap = android.graphics.Paint.Cap.ROUND
+ color = android.graphics.Color.WHITE
+ }
+ val armPx = handleRadiusPx * 1.6f
+ for (signX in intArrayOf(-1, 1)) {
+ for (signY in intArrayOf(-1, 1)) {
+ val corner = toContentPixel(
+ signX * (contentHalfWidth - POINTER_INDICATOR_GAP_METERS),
+ signY * (contentHalfHeight - POINTER_INDICATOR_GAP_METERS),
+ )
+ val (alpha, strokeWidth) = zoneStyle(signX, signY, isCorner = true)
+ handlePaint.alpha = alpha
+ handlePaint.strokeWidth = strokeWidth
+ // toPixel's Y axis is flipped relative to world-space localY (py DECREASES as
+ // localY increases), so "inward" (toward center) is corner.y + signY*armPx, not
+ // corner.y - signY*armPx.
+ canvas.drawLine(corner[0], corner[1], corner[0] - signX * armPx, corner[1], handlePaint)
+ canvas.drawLine(corner[0], corner[1], corner[0], corner[1] + signY * armPx, handlePaint)
+ }
+ }
+ for (signY in intArrayOf(-1, 1)) {
+ val center = toContentPixel(0f, signY * (contentHalfHeight - POINTER_INDICATOR_GAP_METERS))
+ val (alpha, strokeWidth) = zoneStyle(0, signY, isCorner = false)
+ handlePaint.alpha = alpha
+ handlePaint.strokeWidth = strokeWidth
+ canvas.drawLine(center[0] - armPx, center[1], center[0] + armPx, center[1], handlePaint)
+ }
+ }
+
+ /** Ray/quad-plane intersection, returning quad-LOCAL coordinates (meters, origin at the
+ * quad's own center, +X along the quad's own right edge, +Y up) directly — not world
+ * coordinates. The quad orbits the player rather than sliding on a flat, world-axis-aligned
+ * plane once [horizontal] != 0 (see xr_immersive.cpp's submitQuadLayer for why and the exact
+ * derivation this mirrors), so "local" is no longer a plain world-coordinate subtraction: this
+ * recomputes the quad's actual position + rotated right/normal axes from the same
+ * (horizontal, vertical, distance) triple passed in, then projects the hit point onto them.
+ * Callers pass either the LIVE quad transform (for hover/click) or a snapshot frozen at
+ * grab-start (for a drag's fixed reference plane — see updateActiveGrab). */
+ private fun rayPlaneHit(handPoses: FloatArray, hand: Int, horizontal: Float, vertical: Float, distance: Float): FloatArray? {
+ val base = hand * 6
+ val originX = handPoses[base]
+ val originY = handPoses[base + 1]
+ val originZ = handPoses[base + 2]
+ val dirX = handPoses[base + 3]
+ val dirY = handPoses[base + 4]
+ val dirZ = handPoses[base + 5]
+
+ // Same yaw/pitch/position/basis derivation as xr_immersive.cpp's submitQuadLayer — keep
+ // the two in sync, since this must describe the SAME quad the player actually sees. The
+ // quad orbits the player on both axes (see that function's kdoc for the full derivation),
+ // so "local" here means projected onto the quad's own right/up axes at its orbited
+ // position, not a plain world-coordinate subtraction.
+ val yaw = if (distance > 0.0001f) kotlin.math.atan2(horizontal, distance) else 0f
+ val pitch = if (distance > 0.0001f) kotlin.math.atan2(vertical, distance) else 0f
+ val sinYaw = kotlin.math.sin(yaw)
+ val cosYaw = kotlin.math.cos(yaw)
+ val sinPitch = kotlin.math.sin(pitch)
+ val cosPitch = kotlin.math.cos(pitch)
+
+ val centerX = distance * sinYaw * cosPitch
+ val centerY = distance * sinPitch
+ val centerZ = -distance * cosYaw * cosPitch
+
+ // Right axis is unaffected by pitch (pitch rotates about the quad's own local X, i.e.
+ // the right axis itself) — only yaw moves it.
+ val rightX = cosYaw
+ val rightY = 0f
+ val rightZ = sinYaw
+ // Up and normal both pick up the pitch tilt.
+ val upX = -sinYaw * sinPitch
+ val upY = cosPitch
+ val upZ = cosYaw * sinPitch
+ val normalX = -sinYaw * cosPitch
+ val normalY = -sinPitch
+ val normalZ = cosYaw * cosPitch
+
+ val denom = dirX * normalX + dirY * normalY + dirZ * normalZ
+ if (kotlin.math.abs(denom) < 0.0001f) return null
+ val t = ((centerX - originX) * normalX + (centerY - originY) * normalY + (centerZ - originZ) * normalZ) / denom
+ if (t <= 0f) return null
+
+ val hitX = originX + t * dirX
+ val hitY = originY + t * dirY
+ val hitZ = originZ + t * dirZ
+ val relX = hitX - centerX
+ val relY = hitY - centerY
+ val relZ = hitZ - centerZ
+ val localX = relX * rightX + relY * rightY + relZ * rightZ
+ val localY = relX * upX + relY * upY + relZ * upZ
+ return floatArrayOf(localX, localY)
+ }
+
+ /** Whether a quad-local point is near a corner (resize handle) or the top/bottom edge (move
+ * handle) — shared by the actual grab check and the hover-highlight drawn for the cursor, so
+ * the user can see which zone they're in before pulling the grip, not just guess. */
+ private fun nearGrabZone(localX: Float, localY: Float, halfWidth: Float, halfHeight: Float): Pair {
+ // Gated entirely on the quick-menu toggle now — see its kdoc for why (no margin band left
+ // to require "outside" of; handles/grab-zones only exist at all when explicitly asked
+ // for). Proximity to a corner/edge decides it from here, with the corner/bar "center"
+ // pinned to the actual corner/edge (±halfWidth/halfHeight, never 0) rather than
+ // kotlin.math.sign(x)*half (which collapses to 0 exactly at x=0, i.e. dead center) — the
+ // margin-era version never hit that edge case because it additionally required being
+ // outside the content, which excluded the center by construction; without that gate, a
+ // sign()-based center needs this instead to avoid a false match at the exact center.
+ if (!resizeHandlesEnabled) return false to false
+ val cornerCenterX = if (localX >= 0f) halfWidth else -halfWidth
+ val cornerCenterY = if (localY >= 0f) halfHeight else -halfHeight
+ val nearCorner = kotlin.math.hypot(localX - cornerCenterX, localY - cornerCenterY) < POINTER_CORNER_RADIUS_METERS
+ val barCenterY = if (localY >= 0f) halfHeight else -halfHeight
+ val nearEdgeBar = kotlin.math.abs(localX) < POINTER_BAR_HALF_WIDTH_METERS &&
+ kotlin.math.abs(localY - barCenterY) < POINTER_BAR_RADIUS_METERS
+ return nearCorner to nearEdgeBar
+ }
+
+ /** Returns true if a grab actually started — callers use this to skip a simultaneous click
+ * dispatch for the same press (see handlePointerMode's kdoc on the unified action button). */
+ private fun tryStartPointerGrab(hand: Int, handPoses: FloatArray): Boolean {
+ val hit = rayPlaneHit(handPoses, hand, quadHorizontal, quadVertical, quadDistance)
+ if (hit == null) {
+ Timber.i("Immersive: action pressed hand=%d but ray never crossed the quad plane (behind the hand, or pointing away)", hand)
+ return false
+ }
+ // BASE_WIDTH/HEIGHT*scale IS the game content's own true half-extent in this "margin is
+ // ADDED, never shrinks the game" model. hit is already quad-local (rayPlaneHit projects
+ // onto the quad's own basis) — no further conversion needed.
+ val contentHalfWidth = (ImmersiveControls.BASE_WIDTH_METERS * quadScale) / 2f
+ val contentHalfHeight = (ImmersiveControls.BASE_HEIGHT_METERS * quadScale) / 2f
+ val localX = hit[0]
+ val localY = hit[1]
+
+ val (nearCorner, nearEdgeBar) = nearGrabZone(localX, localY, contentHalfWidth, contentHalfHeight)
+
+ if (!nearCorner && !nearEdgeBar) {
+ Timber.i(
+ "Immersive: action pressed hand=%d but not in a grab zone — local=(%.2f,%.2f) contentHalf=(%.2f,%.2f) cornerRadius=%.2f barHalfWidth=%.2f barRadius=%.2f",
+ hand, localX, localY, contentHalfWidth, contentHalfHeight,
+ POINTER_CORNER_RADIUS_METERS, POINTER_BAR_HALF_WIDTH_METERS, POINTER_BAR_RADIUS_METERS,
+ )
+ return false
+ }
+
+ pointerGrabHand = hand
+ pointerGrabIsResize = nearCorner
+ // Grab-start reference is the RAY'S HIT POINT on the quad plane, not the hand's raw 3D
+ // position — a small wrist rotation moves the hit point far more than it moves the hand
+ // itself (the further the quad, the bigger that amplification), so tracking the hit point
+ // is what makes move/resize actually feel like "the window follows the controller" instead
+ // of a sluggish 1:1 hand-translation mapping that barely responds to aiming/rotation at
+ // all. Z is the exception — there's no "hit point" analog for push/pull distance, so that
+ // axis still tracks the hand's raw forward/back position.
+ pointerGrabStartHandPos[0] = hit[0]
+ pointerGrabStartHandPos[1] = hit[1]
+ pointerGrabStartHandPos[2] = handPoses[hand * 6 + 2]
+ pointerGrabStartDistance = quadDistance
+ pointerGrabStartHorizontal = quadHorizontal
+ pointerGrabStartVertical = quadVertical
+ pointerGrabStartScale = quadScale
+ Timber.i("Immersive: pointer grab started hand=%d resize=%b", hand, nearCorner)
+ return true
+ }
+
+ private fun updateActiveGrab(handPoses: FloatArray) {
+ val hand = pointerGrabHand ?: return
+ val base = hand * 6
+ // Fixed reference plane frozen at grab-start (position AND orientation, via the frozen
+ // pointerGrabStart{Horizontal,Vertical,Distance} triple) — recomputing against a plane
+ // that itself moves/rotates mid-drag would fight the very delta it's trying to measure.
+ // rayPlaneHit returns coordinates already local to THIS fixed plane, matching
+ // pointerGrabStartHandPos (captured the same way in tryStartPointerGrab), so both are
+ // directly comparable without any extra offset math.
+ val currentHit = rayPlaneHit(handPoses, hand, pointerGrabStartHorizontal, pointerGrabStartVertical, pointerGrabStartDistance)
+ if (currentHit == null) return // ray no longer crosses the reference plane — hold, don't jump
+ val dx = currentHit[0] - pointerGrabStartHandPos[0]
+ val dy = currentHit[1] - pointerGrabStartHandPos[1]
+ val dz = handPoses[base + 2] - pointerGrabStartHandPos[2]
+
+ if (pointerGrabIsResize) {
+ // Scale by how far the hit point's distance from the quad's center (fixed during a
+ // resize — only scale changes, not position) has changed since grab-start — a single
+ // uniform ratio applied to both width and height via quadScale, so the aspect ratio
+ // can never change no matter which direction the hand drags. Both hit points are
+ // already local (center-relative), so distance-from-center is just their own hypot.
+ val startDist = kotlin.math.hypot(pointerGrabStartHandPos[0], pointerGrabStartHandPos[1])
+ val newDist = kotlin.math.hypot(currentHit[0], currentHit[1])
+ if (startDist > 0.0001f) {
+ val newScale = (pointerGrabStartScale * (newDist / startDist))
+ .coerceIn(ImmersiveControls.MIN_SCALE, ImmersiveControls.MAX_SCALE)
+ quadScale = newScale
+ }
+ } else {
+ quadHorizontal = (pointerGrabStartHorizontal + dx)
+ .coerceIn(ImmersiveControls.MIN_OFFSET, ImmersiveControls.MAX_OFFSET)
+ quadVertical = (pointerGrabStartVertical + dy)
+ .coerceIn(ImmersiveControls.MIN_OFFSET, ImmersiveControls.MAX_OFFSET)
+ quadDistance = (pointerGrabStartDistance - dz)
+ .coerceIn(ImmersiveControls.MIN_DISTANCE, ImmersiveControls.MAX_DISTANCE)
+ }
+ applyQuadTransform()
+ }
+
+ /** Translates left-stick + A/B into dpad/select/back KeyEvents so QuickMenu's existing
+ * focusable()/FocusRequester navigation (built for a real gamepad's KeyEvents) works, instead
+ * of feeding Wine while the menu — which pauses the game anyway — is on screen. */
+ private fun handleMenuNavigation(buttons: Int, axes: FloatArray) {
+ val leftX = axes[0]
+ val leftY = axes[1]
+ val direction = when {
+ // leftY is already sign-flipped upstream (syncControllerInputs) to match XInput's
+ // "positive = up" convention for Wine — Compose/Android's own dpad convention wants
+ // the opposite here, so UP/DOWN are swapped back relative to the Wine-facing mapping.
+ leftY > MENU_DPAD_AXIS_THRESHOLD -> android.view.KeyEvent.KEYCODE_DPAD_DOWN
+ leftY < -MENU_DPAD_AXIS_THRESHOLD -> android.view.KeyEvent.KEYCODE_DPAD_UP
+ leftX < -MENU_DPAD_AXIS_THRESHOLD -> android.view.KeyEvent.KEYCODE_DPAD_LEFT
+ leftX > MENU_DPAD_AXIS_THRESHOLD -> android.view.KeyEvent.KEYCODE_DPAD_RIGHT
+ else -> null
+ }
+ val now = System.currentTimeMillis()
+ when {
+ direction == null -> lastMenuDpadKeyCode = null
+ direction != lastMenuDpadKeyCode -> {
+ Timber.i(
+ "Immersive: menu dpad direction=%d leftX=%.2f leftY=%.2f quickMenuVisible=%b isOverlayPaused=%b",
+ direction, leftX, leftY, quickMenuVisible, PluviaApp.isOverlayPaused,
+ )
+ triggerMenuDirection(direction)
+ lastMenuDpadKeyCode = direction
+ lastMenuDpadHeldSince = now
+ lastMenuDpadEventTime = now
+ }
+ now - lastMenuDpadHeldSince > MENU_DPAD_INITIAL_DELAY_MS &&
+ now - lastMenuDpadEventTime > MENU_DPAD_REPEAT_DELAY_MS -> {
+ triggerMenuDirection(direction)
+ lastMenuDpadEventTime = now
+ }
+ }
+
+ val buttonAPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_A)) != 0
+ if (buttonAPressed && !lastMenuButtonAPressed) {
+ // Radio/toggle rows (ScreenEffectRadioRow, upscale-mode choices) rely on Android's
+ // default DPAD_CENTER/A-click-on-real-focused-view behavior, which is unreliable here
+ // since some other View — not Compose's own focus state — holds real view focus.
+ // quickMenuFocusedActivate reports whichever row is currently focused; invoke it
+ // directly when present instead of relying on that default click path.
+ val focusedActivate = quickMenuFocusedActivate?.invoke()
+ Timber.i("Immersive: A pressed, focusedActivatePresent=%b", focusedActivate != null)
+ if (focusedActivate != null) {
+ runOnUiThread { focusedActivate.invoke() }
+ } else {
+ // DPAD_CENTER activates plain Modifier.clickable() targets (toggles, the Resume
+ // button); BUTTON_A specifically is what QuickMenuAdjustmentRow's slider-lock and
+ // XServerScreen's manualResumeMode handler check for (see their onPreviewKeyEvent /
+ // onKeyEvent) — send both so either kind of target responds to the same press.
+ dispatchMenuKeyEvent(android.view.KeyEvent.KEYCODE_DPAD_CENTER)
+ dispatchMenuKeyEvent(android.view.KeyEvent.KEYCODE_BUTTON_A)
+ }
+ }
+ lastMenuButtonAPressed = buttonAPressed
+
+ val buttonBPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_B)) != 0
+ if (buttonBPressed && !lastMenuButtonBPressed) dispatchMenuKeyEvent(android.view.KeyEvent.KEYCODE_BACK)
+ lastMenuButtonBPressed = buttonBPressed
+
+ // LB/RB cycle the quick-menu's tab rail directly via quickMenuCycleTab — reported as
+ // unreachable via dpad-left from inside a tab's content, and dispatchMenuKeyEvent's
+ // KEYCODE_BUTTON_L1/R1 goes through the same broken KeyEvent path as arrow-key movement
+ // did (confirmed via logging), so this bypasses it the same way moveMenuFocus does.
+ // stickClickHeld dates back to when XR-pointer-mode activation was a grip+trigger chord,
+ // which could briefly co-occur with a real LB/RB press and get misread as a deliberate
+ // tab-cycle pair. That chord is gone — XR pointer mode now toggles via a thumbstick
+ // double-click (native pointerModeToggled), which never touches the grips — but the gate
+ // is left in place as a harmless safeguard against any future chord that reintroduces
+ // the same overlap.
+ val stickClickHeld = (buttons and ((1 shl XrGamepadBridge.BUTTON_L3) or (1 shl XrGamepadBridge.BUTTON_R3))) != 0
+ // Also gated on quickMenuVisible specifically (not just this function running, which
+ // ALSO happens whenever isOverlayPaused alone is true — e.g. a fullscreen "press A to
+ // resume" prompt with no tab rail at all) — logs showed LB/RB still "cycling" a tab
+ // nobody could see in that state, silently leaving the quick menu on a different tab
+ // than expected the next time it actually opened.
+ val buttonLBPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_LB)) != 0
+ if (buttonLBPressed && !lastMenuButtonLBPressed && !stickClickHeld && quickMenuVisible) {
+ Timber.i("Immersive: LB pressed, cycleTabPresent=%b", quickMenuCycleTab != null)
+ runOnUiThread { quickMenuCycleTab?.invoke(false) }
+ }
+ lastMenuButtonLBPressed = buttonLBPressed
+
+ val buttonRBPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_RB)) != 0
+ if (buttonRBPressed && !lastMenuButtonRBPressed && !stickClickHeld && quickMenuVisible) {
+ Timber.i("Immersive: RB pressed, cycleTabPresent=%b", quickMenuCycleTab != null)
+ runOnUiThread { quickMenuCycleTab?.invoke(true) }
+ }
+ lastMenuButtonRBPressed = buttonRBPressed
+ }
+
+ private fun dispatchMenuKeyEvent(keyCode: Int) {
+ runOnUiThread {
+ val time = android.os.SystemClock.uptimeMillis()
+ dispatchMenuKeyEventInternal(android.view.KeyEvent(time, time, android.view.KeyEvent.ACTION_DOWN, keyCode, 0))
+ dispatchMenuKeyEventInternal(android.view.KeyEvent(time, time, android.view.KeyEvent.ACTION_UP, keyCode, 0))
+ }
+ }
+
+ /** Routes a dpad direction to a locked slider's decrease/increase if one is active, then to
+ * the tab-rail bypass for LEFT specifically, otherwise moves focus as normal.
+ *
+ * A locked QuickMenuAdjustmentRow's own onPreviewKeyEvent (listening for DPAD_LEFT/RIGHT)
+ * never sees these presses at all — moveMenuFocus's direct FocusManager bypass intercepts
+ * arrow keys before they'd ever reach Compose's key dispatch, so without this, a locked
+ * slider's LEFT/RIGHT just moved focus away mid-adjustment instead of changing its value.
+ * quickMenuAdjustmentControl (see its own kdoc) is invoked fresh on every call — it's a live
+ * getter, not a snapshot — so it always reflects whichever row (if any) is currently locked.
+ *
+ * LEFT (when no slider is locked) goes through quickMenuFocusTabRail instead of
+ * moveMenuFocus — moveFocus(Left) reaching the tab rail from content depends on the focused
+ * row's vertical position lining up with a rail icon in Compose's spatial search, confirmed
+ * unreliable by comparing two otherwise-identical sessions' logs, so this always jumps
+ * straight to the current tab's own rail button instead of gambling on that heuristic. */
+ private fun triggerMenuDirection(keyCode: Int) {
+ val active = quickMenuAdjustmentControl?.invoke()
+ if (active != null &&
+ (keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT || keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT)
+ ) {
+ val (onDecrease, onIncrease) = active
+ // Logged specifically to distinguish this branch from the moveFocus one — with no
+ // log here, a press silently landing in this branch vs. silently landing in
+ // moveMenuFocus (e.g. because the lock had already dropped) look IDENTICAL from the
+ // outside, which is exactly the ambiguity that made the "one tick works, then nothing"
+ // reports hard to pin down from logs alone.
+ Timber.i("Immersive: adjustment bypass triggered keyCode=%d (locked row's decrease/increase)", keyCode)
+ // Must run on the UI thread — this drives real Compose/ViewModel state (fps limiter,
+ // HUD config, etc.) whose recomposition is tied to the UI thread's frame clock. Calling
+ // it straight from this polling thread was a real bug: the resulting recomposition
+ // could race with Compose's own focus-commit step, occasionally unfocusing the row
+ // right as it recomposed — which this row's own .onFocusChanged then read as "lost
+ // focus" and force-unlocked (isAdjustmentLocked = false), matching exactly what showed
+ // up as "one adjustment works, then LEFT/RIGHT do nothing until B then A again."
+ runOnUiThread { if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT) onDecrease() else onIncrease() }
+ } else if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT) {
+ runOnUiThread { quickMenuFocusTabRail?.invoke() }
+ } else {
+ moveMenuFocus(keyCode)
+ }
+ }
+
+ /** Moves Compose focus directly via QuickMenu's registered FocusManager, instead of
+ * dispatching a synthetic dpad KeyEvent through dispatchMenuKeyEvent()/Activity.dispatchKeyEvent()
+ * — logging confirmed those never reach Compose's key dispatch at all in this Activity (some
+ * other Android View holds real view-level focus throughout), so DPAD_CENTER/BUTTON_A/BACK
+ * still go through dispatchMenuKeyEvent (confirmed working), but arrow-key focus movement
+ * needs this direct bypass instead. */
+ private fun moveMenuFocus(keyCode: Int) {
+ val direction = when (keyCode) {
+ android.view.KeyEvent.KEYCODE_DPAD_DOWN -> androidx.compose.ui.focus.FocusDirection.Down
+ android.view.KeyEvent.KEYCODE_DPAD_UP -> androidx.compose.ui.focus.FocusDirection.Up
+ android.view.KeyEvent.KEYCODE_DPAD_LEFT -> androidx.compose.ui.focus.FocusDirection.Left
+ android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> androidx.compose.ui.focus.FocusDirection.Right
+ else -> return
+ }
+ runOnUiThread {
+ val moved = quickMenuFocusManager?.moveFocus(direction)
+ Timber.i("Immersive: moveFocus direction=%s focusManagerPresent=%b result=%s", direction, quickMenuFocusManager != null, moved)
+ }
+ }
+
+ // XServerScreen's key handling (manualResumeMode's BUTTON_A/ENTER check, WinHandler gamepad
+ // forwarding, the ESC-menu handler) all subscribe to PluviaApp.events' AndroidEvent.KeyEvent
+ // bus, which only MainActivity's dispatchKeyEvent() feeds — ImmersiveXrActivity has no
+ // equivalent override, so without this, none of that logic ever sees our synthetic events.
+ // Mirror MainActivity's dispatchKeyEvent: emit on the bus first, fall back to the normal
+ // Android/Compose focus dispatch (dpad nav, plain clickable()) if nothing consumed it.
+ private fun dispatchMenuKeyEventInternal(event: android.view.KeyEvent) {
+ val consumed = PluviaApp.events.emit(app.gamenative.events.AndroidEvent.KeyEvent(event)) { results ->
+ results.any { it }
+ } == true
+ val fallbackHandled = if (!consumed) dispatchKeyEvent(event) else false
+ if (event.action == android.view.KeyEvent.ACTION_DOWN) {
+ Timber.i(
+ "Immersive: menu key dispatch keyCode=%d consumedByBus=%b dispatchKeyEvent=%b",
+ event.keyCode, consumed, fallbackHandled,
+ )
+ }
+ }
+
+ /**
+ * Two decoupled rates, not one unconditional per-frame path:
+ *
+ * - The GAME frame: PixelCopy self-chains as fast as it completes (same as the original
+ * gameplay-only capture) on a background thread. Compositing it with the cached overlay
+ * layer below it is plain bitmap-to-bitmap drawing — cheap, no View access needed — so it
+ * stays on that same background thread and nativeSubmitFrame() runs at full game-frame
+ * rate, same as before the overlay was ever unified in.
+ * - The OVERLAY layer (quick menu, Resume button, performance HUD): re-rendered separately
+ * on a timer (~[OVERLAY_REFRESH_INTERVAL_MS]) via a real View.draw(Canvas) on the UI
+ * thread into its own cached bitmap — transparent everywhere except whatever overlay UI is
+ * actually showing.
+ *
+ * An earlier version ran the View.draw() step on *every* game frame, which (confirmed by
+ * testing) tanked performance badly enough to crash — walking the whole Compose tree at
+ * native-game-frame-rate is far more than a menu/HUD actually needs. Splitting the rates
+ * keeps the HUD/menu always visible (the thing that motivated unifying the two capture modes
+ * in the first place) without paying a per-frame Compose-tree cost on the game's own rate.
+ *
+ * [overlayLock] guards [overlayLayerBitmap] since the fast (background) and slow (UI) loops
+ * touch it from different threads.
+ */
+ private fun startFrameCaptureLoop() {
+ captureThread = HandlerThread("XrFrameCapture").apply { start() }
+ captureHandler = Handler(captureThread!!.looper)
+ captureActive.set(true)
+ scheduleNextCapture()
+ scheduleNextOverlayRefresh()
+ }
+
+ /** Attaches a direct-render bridge to the running container's renderer, whichever it turns
+ * out to be — called (idempotently, see the guard below) from [scheduleNextCapture]'s retry
+ * loop rather than once at session start, since the game's XServerView/renderer isn't created
+ * yet at that point for a typical launch. */
+ private fun setupDirectRenderBridgeIfSupported() {
+ if (directGLBridge != null || directVulkanBridge != null) return
+ val actualRenderer = PluviaApp.xServerView?.renderer
+
+ val glRenderer = actualRenderer as? GLRenderer
+ if (glRenderer != null) {
+ val bridge = DirectGLBridge(
+ onBufferReady = { buffer ->
+ if (xrSessionHandle != 0L) XrNative.nativeSetSharedGameBuffer(xrSessionHandle, buffer)
+ directRenderActive = true
+ applyQuadTransform()
+ Timber.i("Immersive: direct GL render path active — game frame now shared via GPU buffer, PixelCopy of the game layer stopped")
+ },
+ )
+ directGLBridge = bridge
+ glRenderer.setXrFrameBridge(bridge)
+ Timber.i("Immersive: GLRenderer detected — direct-render bridge attached, buffer import pending first frame")
+ return
+ }
+
+ val vulkanRenderer = actualRenderer as? com.winlator.renderer.VulkanRenderer
+ if (vulkanRenderer != null) {
+ val bridge = DirectVulkanBridge(
+ onFrame = { ahbPtr, _, _ ->
+ if (xrSessionHandle != 0L) {
+ XrNative.nativeSetSharedGameBufferPtr(xrSessionHandle, ahbPtr)
+ if (!directRenderActive) {
+ directRenderActive = true
+ applyQuadTransform()
+ }
+ }
+ },
+ )
+ directVulkanBridge = bridge
+ vulkanRenderer.setVulkanXrFrameBridge(bridge)
+ Timber.i("Immersive: VulkanRenderer detected — direct-render bridge attached, waiting for its first AHardwareBuffer (PixelCopy stays as fallback until then)")
+ return
+ }
+
+ if (actualRenderer != null && !directRenderProbeLogged) {
+ directRenderProbeLogged = true
+ Timber.i("Immersive: renderer is %s (unrecognized) — direct GPU render path unavailable, staying on PixelCopy", actualRenderer.javaClass.simpleName)
+ }
+ }
+
+ private fun teardownDirectRenderBridge() {
+ directVulkanBridge = null
+ (PluviaApp.xServerView?.renderer as? com.winlator.renderer.VulkanRenderer)?.setVulkanXrFrameBridge(null)
+ val bridge = directGLBridge
+ if (bridge != null) {
+ directGLBridge = null
+ (PluviaApp.xServerView?.renderer as? GLRenderer)?.setXrFrameBridge(null)
+ PluviaApp.xServerView?.queueEvent { bridge.release() }
+ }
+ directRenderActive = false
+ }
+
+ private fun scheduleNextCapture() {
+ if (!captureActive.get()) return
+ val handler = captureHandler ?: return
+
+ if (directGLBridge == null && directVulkanBridge == null) setupDirectRenderBridgeIfSupported()
+
+ val surfaceView = PluviaApp.xServerView as? SurfaceView
+ val width = surfaceView?.width ?: 0
+ val height = surfaceView?.height ?: 0
+
+ val glBridge = directGLBridge
+ if (glBridge != null) {
+ // Direct-render path: GLRenderer writes the game frame straight into the shared GPU
+ // buffer from its own thread — no PixelCopy/CPU readback needed for the game layer
+ // at all. Just keep the shared buffer sized to the surface (ensureAllocated no-ops
+ // once already the right size) and skip everything below.
+ if (width > 0 && height > 0) {
+ PluviaApp.xServerView?.queueEvent { glBridge.ensureAllocated(width, height) }
+ }
+ handler.postDelayed({ scheduleNextCapture() }, CAPTURE_RETRY_DELAY_MS)
+ return
+ }
+
+ if (directVulkanBridge != null && directRenderActive) {
+ // Confirmed: VulkanRenderer is already feeding frames straight to the
+ // native session on its own cadence (see onScanoutBuffer) — nothing to poll here.
+ // Until directRenderActive is actually confirmed true, fall through to PixelCopy
+ // below instead (unexpected if it never fires, but PixelCopy staying up is a safe fallback).
+ handler.postDelayed({ scheduleNextCapture() }, CAPTURE_RETRY_DELAY_MS)
+ return
+ }
+
+ if (surfaceView != null && surfaceView !== surfaceCallbackAttachedTo) {
+ surfaceCallbackAttachedTo?.holder?.removeCallback(surfaceReadyCallback)
+ // addCallback() does NOT retroactively fire surfaceCreated() if the Surface already
+ // existed at attach time — seed from isValid() once, then let the callback maintain
+ // it from here on (see the surfaceReady kdoc for why the callback, not polling
+ // isValid() on every attempt, is what actually narrows the crash window).
+ surfaceReady = surfaceView.holder?.surface?.isValid == true
+ surfaceView.holder.addCallback(surfaceReadyCallback)
+ surfaceCallbackAttachedTo = surfaceView
+ }
+
+ if (surfaceView == null || width <= 0 || height <= 0 || !surfaceReady) {
+ handler.postDelayed({ scheduleNextCapture() }, CAPTURE_RETRY_DELAY_MS)
+ return
+ }
+
+ val bitmap = gameCaptureBitmap?.takeIf { it.width == width && it.height == height }
+ ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also {
+ // PixelCopy from this (opaque) SurfaceView leaves every pixel's alpha byte at 0
+ // (confirmed via a diagnostic pixel sample during development) even though the
+ // RGB content is real — Canvas.drawBitmap's default SRC_OVER blend then treats
+ // alpha=0 as "draw nothing", silently no-op'ing any attempt to use this as a
+ // background (see compositeGameFrame). setHasAlpha(false) tells the renderer to
+ // ignore that channel and treat every pixel as fully opaque.
+ it.setHasAlpha(false)
+ gameCaptureBitmap = it
+ }
+
+ // Re-check right before the call too — belt and suspenders, cheap, and narrows the
+ // window a little further given the bitmap allocation above can take a moment.
+ if (!surfaceReady || surfaceView.holder?.surface?.isValid != true) {
+ handler.postDelayed({ scheduleNextCapture() }, CAPTURE_RETRY_DELAY_MS)
+ return
+ }
+
+ val captureStartTime = android.os.SystemClock.uptimeMillis()
+ try {
+ PixelCopy.request(surfaceView, bitmap, { result ->
+ if (result == PixelCopy.SUCCESS) {
+ compositeGameFrame(width, height)
+ }
+ // No cap here previously — this self-chained as fast as PixelCopy completed,
+ // which can be much faster than the game itself is actually rendering new frames
+ // (PixelCopy will happily hand back the same unchanged buffer instantly). Every
+ // one of those "wasted" captures is still a real GPU readback competing with the
+ // game's own rendering for the same GPU, on top of the XR compositor being fixed
+ // at the headset's own refresh rate regardless (so capturing faster than that is
+ // pure waste on that end too). Capping to that rate removes the wasted work
+ // without capping anything the display could actually show.
+ val elapsed = android.os.SystemClock.uptimeMillis() - captureStartTime
+ val delay = (MIN_CAPTURE_INTERVAL_MS - elapsed).coerceAtLeast(0L)
+ if (delay > 0L) handler.postDelayed({ scheduleNextCapture() }, delay) else scheduleNextCapture()
+ }, handler)
+ } catch (t: Throwable) {
+ Timber.w(t, "PixelCopy request failed")
+ handler.postDelayed({ scheduleNextCapture() }, CAPTURE_RETRY_DELAY_MS)
+ }
+ }
+
+ /** Fast path: cheap bitmap composite (game frame + cached overlay layer), runs on the capture
+ * thread at full game-frame rate. No View access here — see the capture-loop kdoc above.
+ * This path only runs when NOT direct-rendering (see scheduleNextCapture).
+ *
+ * No pointer-border margin here (or anywhere else in this class) — an earlier version grew
+ * the quad by a fixed margin band on every side, purely to give resize/move handles a place
+ * to live outside the game content. That cost a real, measured chunk of the direct-render
+ * path's fixed swapchain pixel budget (~45% of the pixel area at default scale, since the
+ * margin was a FIXED size taken out of the SAME fixed-resolution swapchain the game itself
+ * has to share) — visible as pixelation the user explicitly did not accept as a tradeoff.
+ * Handles now live INSIDE the game content instead (see drawPointerCursors/nearGrabZone),
+ * gated behind [resizeHandlesEnabled] so they're only ever drawn/grabbable when the user has
+ * explicitly asked for them via the quick menu's toggle — the game's own displayed size is
+ * therefore identical in every controller mode AND never grows for a margin that no longer
+ * exists, at full sharpness always. */
+ private fun compositeGameFrame(width: Int, height: Int) {
+ if (!captureActive.get() || xrSessionHandle == 0L) return
+ val gameFrame = gameCaptureBitmap ?: return
+
+ val finalBitmap = finalFrameBitmap?.takeIf { it.width == width && it.height == height }
+ ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { finalFrameBitmap = it }
+
+ val canvas = android.graphics.Canvas(finalBitmap)
+ canvas.drawBitmap(gameFrame, 0f, 0f, null)
+ synchronized(overlayLock) {
+ overlayLayerBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) }
+ }
+ if (xrPointerModeActive) {
+ drawPointerCursors(canvas, width, height)
+ }
+ XrNative.nativeSubmitFrame(xrSessionHandle, finalBitmap)
+ }
+
+ /** Slow path: re-renders just the Compose overlay (transparent where the game is) into
+ * [overlayLayerBitmap], on its own throttled timer, on the UI thread (required for
+ * View.draw). Self-chaining like the capture loop, independent of it. */
+ private fun scheduleNextOverlayRefresh() {
+ if (!captureActive.get()) return
+ val handler = captureHandler ?: return
+ handler.postDelayed({ refreshOverlayLayer() }, OVERLAY_REFRESH_INTERVAL_MS)
+ }
+
+ private fun refreshOverlayLayer() {
+ runOnUiThread {
+ if (!captureActive.get()) {
+ return@runOnUiThread
+ }
+
+ val contentView = findViewById(android.R.id.content)
+ val width = contentView.width
+ val height = contentView.height
+ if (width <= 0 || height <= 0) {
+ scheduleNextOverlayRefresh()
+ return@runOnUiThread
+ }
+
+ // SurfaceView reserves a "hole" wherever it sits in the view tree — drawing an
+ // ancestor via View.draw(Canvas) (not the real windowed rendering pipeline) paints
+ // that hole as solid BLACK, since there's no separate compositor layer for it to show
+ // through in an offscreen bitmap.
+ //
+ // A previous version toggled the SurfaceView's *visibility* around this draw call,
+ // which turned out to be the actual cause of two native crashes (SIGSEGV inside
+ // PixelCopy's own ANativeWindow_acquire, both starting right at game launch):
+ // visibility changes are a well-known trigger for Android to tear down and recreate
+ // a SurfaceView's underlying Surface, and this call runs every ~33ms for the entire
+ // session — so that "fix" was destroying and recreating the game's real rendering
+ // surface ~30 times a second, directly underneath the capture thread's PixelCopy
+ // calls. The next attempt, clipOutRect(), avoided touching the Surface at all but
+ // over-corrected: it excludes that whole screen-sized rect from the canvas, which
+ // also excludes the menu/HUD, since they're drawn at the same screen coordinates
+ // (clipping works in coordinates, not "this specific child view").
+ //
+ // Alpha instead of visibility: unlike visibility, alpha is a pure compositing
+ // property — it doesn't go through updateSurface()/the attach-detach path that
+ // destroys the Surface — so it should make this View's own contribution (the hole)
+ // draw as fully transparent without ever affecting the real Surface, while the
+ // menu/HUD (their own alpha untouched) still draw normally on top.
+ val surfaceView = PluviaApp.xServerView as? SurfaceView
+ val previousAlpha = surfaceView?.alpha ?: 1f
+
+ try {
+ synchronized(overlayLock) {
+ val bitmap = overlayLayerBitmap?.takeIf { it.width == width && it.height == height }
+ ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { overlayLayerBitmap = it }
+ val canvas = android.graphics.Canvas(bitmap)
+ canvas.drawColor(android.graphics.Color.TRANSPARENT, android.graphics.PorterDuff.Mode.CLEAR)
+ if (surfaceView != null) surfaceView.alpha = 0f
+ try {
+ // No margin anymore (see compositeGameFrame's kdoc) — screen == the whole
+ // visible quad, in both render paths, so this raw capture can be submitted
+ // as-is with no inset/scaling step.
+ contentView.draw(canvas)
+ if (directRenderActive && xrPointerModeActive) {
+ drawPointerCursors(canvas, width, height)
+ }
+ } finally {
+ if (surfaceView != null) surfaceView.alpha = previousAlpha
+ }
+ }
+ if (directRenderActive && xrSessionHandle != 0L) {
+ // The shared GPU buffer already carries the game frame straight from
+ // GLRenderer/VulkanRenderer — this overlay-only bitmap (transparent everywhere
+ // except menu/HUD/cursors) is all nativeSubmitFrame needs to feed the native
+ // dual-sampler blend (see xr_immersive.cpp's directQuadProgram_ branch).
+ synchronized(overlayLock) {
+ overlayLayerBitmap?.let { XrNative.nativeSubmitFrame(xrSessionHandle, it) }
+ }
+ }
+ overlayCaptureLogCounter++
+ if (overlayCaptureLogCounter % 30 == 0) {
+ val centerPx = synchronized(overlayLock) { overlayLayerBitmap?.getPixel(width / 2, height / 2) } ?: 0
+ Timber.i(
+ "Immersive: overlay layer refreshed size=%dx%d centerPx=#%08X sessionHandle=%d",
+ width,
+ height,
+ centerPx,
+ xrSessionHandle,
+ )
+ }
+ } catch (t: Throwable) {
+ Timber.w(t, "Overlay layer refresh failed")
+ }
+ scheduleNextOverlayRefresh()
+ }
+ }
+
+ private fun stopFrameCaptureLoop() {
+ captureActive.set(false)
+ captureThread?.quitSafely()
+ captureThread = null
+ captureHandler = null
+ gameCaptureBitmap = null
+ finalFrameBitmap = null
+ synchronized(overlayLock) { overlayLayerBitmap = null }
+ surfaceCallbackAttachedTo?.holder?.removeCallback(surfaceReadyCallback)
+ surfaceCallbackAttachedTo = null
+ surfaceReady = false
+ }
+
+ private fun stopXrSession() {
+ pollingActive.set(false)
+ pollingThread?.join(500)
+ pollingThread = null
+ stopFrameCaptureLoop()
+ teardownDirectRenderBridge()
+ if (xrSessionHandle != 0L) {
+ XrNative.nativeRequestStop(xrSessionHandle)
+ XrNative.nativeJoinAndDestroy(xrSessionHandle)
+ xrSessionHandle = 0L
+ }
+ }
+}
+
+/** Shown for a few seconds once the game's window actually appears, explaining the two chords
+ * that have no other affordance to discover them (quick menu, XR-pointer-mode toggle). */
+@Composable
+private fun ImmersiveControlsOnboarding(visible: Boolean, onDismiss: () -> Unit) {
+ LaunchedEffect(visible) {
+ if (visible) {
+ delay(7000)
+ onDismiss()
+ }
+ }
+ // contentAlignment on the Box itself, not .align() on the child — same pattern as
+ // ImmersiveModeChangeIndicator below; this was pinned to the top instead of centered.
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ androidx.compose.animation.AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Surface(
+ shape = RoundedCornerShape(20.dp),
+ color = Color.Black.copy(alpha = 0.65f),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 28.dp, vertical = 18.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ Icon(Icons.Default.VisibilityOff, contentDescription = null, tint = Color.White, modifier = Modifier.size(28.dp))
+ Text(stringResource(R.string.immersive_onboarding_disable_passthrough), color = Color.White, style = MaterialTheme.typography.bodyLarge)
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ Icon(Icons.Default.Menu, contentDescription = null, tint = Color.White, modifier = Modifier.size(28.dp))
+ Text("Fais un appui long sur Start pour ouvrir/fermer le menu rapide", color = Color.White, style = MaterialTheme.typography.bodyLarge)
+ }
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ Icon(Icons.Default.SportsEsports, contentDescription = null, tint = Color.White, modifier = Modifier.size(28.dp))
+ Text("Double-clique un joystick pour basculer manette Xbox / manette XR", color = Color.White, style = MaterialTheme.typography.bodyLarge)
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Brief centered indicator, fading in/out, whenever the controller interpretation mode changes
+ * (Xbox gamepad <-> XR pointer) — the only other feedback for that switch is native log output. */
+@Composable
+private fun ImmersiveModeChangeIndicator(pointerModeActive: Boolean) {
+ var visible by remember { mutableStateOf(false) }
+ var shownForPointerMode by remember { mutableStateOf(pointerModeActive) }
+ LaunchedEffect(pointerModeActive) {
+ shownForPointerMode = pointerModeActive
+ visible = true
+ delay(1800)
+ visible = false
+ }
+ // contentAlignment on the Box itself, not .align() on the child — more foolproof against
+ // whatever was pinning this to the top-left before (reported after testing).
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ androidx.compose.animation.AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ color = Color.Black.copy(alpha = 0.65f),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 32.dp, vertical = 20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Icon(
+ imageVector = if (shownForPointerMode) Icons.Default.TouchApp else Icons.Default.SportsEsports,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(36.dp),
+ )
+ Text(
+ text = if (shownForPointerMode) "Mode manette XR" else "Mode manette Xbox",
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/XrGamepadBridge.kt b/app/src/main/java/app/gamenative/ui/screen/xr/XrGamepadBridge.kt
new file mode 100644
index 0000000000..13f9f44f24
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/screen/xr/XrGamepadBridge.kt
@@ -0,0 +1,89 @@
+package app.gamenative.ui.screen.xr
+
+import com.winlator.inputcontrols.GamepadState
+import com.winlator.winhandler.WinHandler
+
+/**
+ * Feeds Meta Quest Touch controller input into Wine as a standard Xbox-layout gamepad.
+ *
+ * This does not touch Android's InputDevice pipeline at all — [WinHandler.sendVirtualGamepadState]
+ * writes straight into the same shared-memory buffer used for real Bluetooth/USB controllers
+ * (see [com.winlator.inputcontrols.ExternalController]), so from the Windows game's point of
+ * view this is indistinguishable from a physical Xbox controller on slot [slot].
+ *
+ * The native OpenXR session calls [updateButton]/[updateThumbstick]/[updateTrigger] once per
+ * frame for whatever changed, then [commit] to push the accumulated state to Wine. No d-pad:
+ * Touch controllers have no physical one, and deriving a fake one from the thumbstick direction
+ * fought with the real analog values (games saw the stick as a d-pad instead of an Xbox stick).
+ */
+class XrGamepadBridge(
+ private val winHandler: WinHandler,
+ private val slot: Int = 0,
+) {
+ companion object {
+ // Bit indices, matching the layout WinHandler.sendVirtualGamepadState already assumes.
+ const val BUTTON_A = 0
+ const val BUTTON_B = 1
+ const val BUTTON_X = 2
+ const val BUTTON_Y = 3
+ const val BUTTON_LB = 4
+ const val BUTTON_RB = 5
+ const val BUTTON_BACK = 6
+ const val BUTTON_START = 7
+ const val BUTTON_L3 = 8
+ const val BUTTON_R3 = 9
+ }
+
+ enum class Stick { LEFT, RIGHT }
+ enum class Trigger { LEFT, RIGHT }
+
+ private val state = GamepadState()
+
+ fun updateButton(buttonIndex: Int, pressed: Boolean) {
+ state.setPressed(buttonIndex, pressed)
+ }
+
+ fun updateThumbstick(stick: Stick, x: Float, y: Float) {
+ val cx = x.coerceIn(-1f, 1f)
+ val cy = y.coerceIn(-1f, 1f)
+ when (stick) {
+ Stick.LEFT -> {
+ state.thumbLX = cx
+ state.thumbLY = cy
+ }
+ Stick.RIGHT -> {
+ state.thumbRX = cx
+ state.thumbRY = cy
+ }
+ }
+ }
+
+ fun updateTrigger(trigger: Trigger, value: Float) {
+ val clamped = value.coerceIn(0f, 1f)
+ when (trigger) {
+ Trigger.LEFT -> state.triggerL = clamped
+ Trigger.RIGHT -> state.triggerR = clamped
+ }
+ }
+
+ /** Pushes the accumulated state to Wine. Call once per frame after the update* calls above. */
+ fun commit() {
+ winHandler.sendVirtualGamepadState(state, slot)
+ }
+
+ /**
+ * Applies one raw snapshot as read from [XrNative.nativePollSnapshot] and commits it.
+ * buttonBitmask bit layout matches [BUTTON_A]..[BUTTON_R3]; axes is
+ * [leftX, leftY, rightX, rightY, triggerL, triggerR].
+ */
+ fun applySnapshot(buttonBitmask: Int, axes: FloatArray) {
+ for (bit in BUTTON_A..BUTTON_R3) {
+ updateButton(bit, (buttonBitmask and (1 shl bit)) != 0)
+ }
+ updateThumbstick(Stick.LEFT, axes[0], axes[1])
+ updateThumbstick(Stick.RIGHT, axes[2], axes[3])
+ updateTrigger(Trigger.LEFT, axes[4])
+ updateTrigger(Trigger.RIGHT, axes[5])
+ commit()
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt b/app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt
new file mode 100644
index 0000000000..73ef517365
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt
@@ -0,0 +1,94 @@
+package app.gamenative.ui.screen.xr
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.hardware.HardwareBuffer
+
+/**
+ * Thin JNI wrapper around the native OpenXR session (app/src/main/cpp/xrimmersive).
+ *
+ * Not wired into the Gradle build by default — see the comment at the top of
+ * app/src/main/cpp/xrimmersive/CMakeLists.txt. Until libxrimmersive.so is built and placed in
+ * jniLibs/, loading this class will throw UnsatisfiedLinkError.
+ */
+object XrNative {
+ init {
+ System.loadLibrary("xrimmersive")
+ }
+
+ /** Starts the OpenXR session on its own native thread. Returns an opaque session handle. */
+ external fun nativeCreate(activity: Context): Long
+
+ /** Signals the native frame-loop thread to stop; does not block. */
+ external fun nativeRequestStop(handle: Long)
+
+ /** Blocks until the native thread has exited, then frees native resources. */
+ external fun nativeJoinAndDestroy(handle: Long)
+
+ /**
+ * Polls the latest controller snapshot.
+ * outButtons must be an IntArray(1): [0] = Xbox-layout button bitmask.
+ * outAxes must be a FloatArray(6): [leftX, leftY, rightX, rightY, triggerL, triggerR].
+ * outHandPoses must be a FloatArray(12): [leftX,leftY,leftZ, leftFwdX,leftFwdY,leftFwdZ,
+ * rightX,rightY,rightZ, rightFwdX,rightFwdY,rightFwdZ] — aim-pose ray per hand in the quad's
+ * local space, meaningful only when outFlags[0] is true.
+ * outFlags must be a BooleanArray(3): [0] = hand poses valid, [1] = the XR-pointer-mode toggle
+ * (double-click of either thumbstick) fired since the last poll, [2] = Menu/Start button is
+ * currently physically held (level, not edge — true for the whole hold).
+ * Returns true if the quick-menu trigger (Start held 600ms) fired since the last poll.
+ */
+ external fun nativePollSnapshot(
+ handle: Long,
+ outButtons: IntArray,
+ outAxes: FloatArray,
+ outHandPoses: FloatArray,
+ outFlags: BooleanArray,
+ ): Boolean
+
+ /**
+ * Hands off one PixelCopy'd frame of the game's actual rendered output (ARGB_8888) to be
+ * drawn into the immersive quad layer. See ImmersiveXrActivity's capture loop.
+ */
+ external fun nativeSubmitFrame(handle: Long, bitmap: Bitmap)
+
+ /**
+ * Repositions/resizes the virtual screen quad. x/y are tangential (left/right, up/down)
+ * offsets and z is always -distance — native reinterprets these as orbiting the player on
+ * both axes at that distance (not plain Cartesian coordinates), so the quad stays facing the
+ * player as x and/or y move away from 0 (see xr_immersive.cpp's submitQuadLayer). width/
+ * height are the quad's real-world size in meters. contentScaleX/Y are content-half-extent /
+ * quad-half-extent per axis (1.0 if there's no margin band beyond the content) — only
+ * consumed by the direct-render shader, see kDirectQuadFragmentShader.
+ */
+ external fun nativeSetQuadTransform(
+ handle: Long,
+ x: Float,
+ y: Float,
+ z: Float,
+ width: Float,
+ height: Float,
+ contentScaleX: Float,
+ contentScaleY: Float,
+ )
+
+ /**
+ * Toggles the XR_FB_passthrough layer behind the quad. No-op if the runtime/manifest
+ * doesn't support it (logged from native, see xr_immersive.cpp's setupPassthrough()).
+ */
+ external fun nativeSetPassthroughEnabled(handle: Long, enabled: Boolean)
+
+ /**
+ * Direct-render path (see DirectGLBridge) — hands the native session the same
+ * [HardwareBuffer] GLRenderer is writing the game's frame into on its own thread, so it can
+ * be imported and sampled directly instead of going through PixelCopy. Pass the same buffer
+ * object every frame; only needs calling again if the buffer identity itself changes.
+ */
+ external fun nativeSetSharedGameBuffer(handle: Long, hardwareBuffer: HardwareBuffer)
+
+ /**
+ * Same direct-render path, for VulkanRenderer's zero-copy scanout output — ahbPtr is the raw
+ * AHardwareBuffer* (as a long) VulkanRenderer already has, with no HardwareBuffer Java object
+ * involved at all. See DirectVulkanBridge/VulkanXrFrameBridge.
+ */
+ external fun nativeSetSharedGameBufferPtr(handle: Long, ahbPtr: Long)
+}
diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
index 377a99097f..5a6a735243 100644
--- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
@@ -29,8 +29,11 @@ import app.gamenative.BuildConfig
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.DropdownMenu
@@ -351,6 +354,39 @@ fun XServerScreen(
onWindowMapped: ((Context, Window) -> Unit)? = null,
onWindowUnmapped: ((Window) -> Unit)? = null,
onGameLaunchError: ((String) -> Unit)? = null,
+ // Non-null only when hosted by ImmersiveXrActivity — presence alone is what makes the
+ // QuickMenu's "Immersif" tab show up.
+ immersiveControls: app.gamenative.ui.screen.xr.ImmersiveControls? = null,
+ // ImmersiveXrActivity needs to know when the quick menu (or any other non-SurfaceView
+ // overlay) is showing, because it captures the game's SurfaceView directly (the only way
+ // to reliably get real frames out of it — capturing the whole Window can't see SurfaceView
+ // content at all, since it's composited on its own layer) and switches to a whole-window
+ // capture only while such an overlay is up, since the game is paused underneath anyway.
+ onQuickMenuVisibilityChanged: (Boolean) -> Unit = {},
+ // See QuickMenu's registerFocusManager kdoc — only meaningful when hosted by
+ // ImmersiveXrActivity, which needs to drive Compose focus directly since dispatching
+ // synthetic dpad KeyEvents through Activity.dispatchKeyEvent() doesn't reach Compose there.
+ registerQuickMenuFocusManager: ((androidx.compose.ui.focus.FocusManager) -> Unit)? = null,
+ // See QuickMenu's registerCycleTab kdoc — same bypass, for LB/RB tab switching.
+ registerQuickMenuCycleTab: (((Boolean) -> Unit) -> Unit)? = null,
+ // See QuickMenu's registerAdjustmentControl kdoc — same bypass, for a locked slider's
+ // left/right drag.
+ registerQuickMenuAdjustmentControl: ((() -> Pair<() -> Unit, () -> Unit>?) -> Unit)? = null,
+ // See QuickMenu's registerFocusTabRail kdoc — same bypass, for LEFT reliably reaching the tab
+ // rail regardless of the focused row's vertical position.
+ registerQuickMenuFocusTabRail: ((() -> Unit) -> Unit)? = null,
+ // See QuickMenu's registerFocusedActivate kdoc — same bypass, for a plain radio/toggle row
+ // whose default DPAD_CENTER/A click doesn't reliably fire in the immersive activity.
+ registerQuickMenuFocusedActivate: ((() -> (() -> Unit)?) -> Unit)? = null,
+ // Registers toggleQuickMenu (open/close only, no IME/controller-rescan/touchpad side
+ // effects) — see its own kdoc. Used by ImmersiveXrActivity's long-press-Start gesture instead
+ // of the shared, side-effect-heavy registerBackAction handler.
+ registerQuickMenuToggle: ((() -> Unit) -> Unit)? = null,
+ // See QuickMenu's registerStartHeld kdoc — pushes the Menu/Start button's physical-hold
+ // state so QuickMenu can suppress the real Android KeyEvents Horizon OS's own gamepad input
+ // dispatch generates while it's held (a separate path from this Activity's native OpenXR
+ // polling/bypasses, immune to all of them).
+ registerQuickMenuStartHeld: (((Boolean) -> Unit) -> Unit)? = null,
) {
Timber.i("Starting up XServerScreen")
val context = LocalContext.current
@@ -522,6 +558,9 @@ fun XServerScreen(
var keyboardRequestedFromOverlay by remember { mutableStateOf(false) }
var shouldForceResumeOnMenuClose by remember { mutableStateOf(false) }
var showQuickMenu by remember { mutableStateOf(false) }
+ LaunchedEffect(showQuickMenu) {
+ onQuickMenuVisibilityChanged(showQuickMenu)
+ }
var quickMenuToolsVisible by remember { mutableStateOf(false) }
var quickMenuWineProcesses by remember { mutableStateOf>(emptyList()) }
var quickMenuWineProcessesLoading by remember { mutableStateOf(false) }
@@ -1026,6 +1065,20 @@ fun XServerScreen(
showQuickMenu = false
}
+ // Dedicated open/close toggle for the immersive activity's long-press-Start gesture —
+ // deliberately NOT gameBack(): that handler also does IME checks, a controller rescan, and
+ // releases touchpad pointer capture on open, none of which belong to "just open/close the
+ // quick menu" and were suspected of causing Start to behave like a confirm/select press.
+ // This does nothing but flip showQuickMenu.
+ val toggleQuickMenu: () -> Unit = {
+ Timber.i("toggleQuickMenu: invoked, showQuickMenu=%b", showQuickMenu)
+ if (showQuickMenu) {
+ dismissOverlayMenu()
+ } else {
+ showQuickMenu = true
+ }
+ }
+
LaunchedEffect(showQuickMenu, quickMenuToolsVisible, xServerView) {
if (!showQuickMenu || !quickMenuToolsVisible) {
quickMenuWineProcesses = emptyList()
@@ -1392,6 +1445,7 @@ fun XServerScreen(
DisposableEffect(container) {
registerBackAction(gameBack)
+ registerQuickMenuToggle?.invoke(toggleQuickMenu)
onDispose {
Timber.d("XServerScreen leaving, clearing back action")
removePerformanceHud()
@@ -1404,6 +1458,7 @@ fun XServerScreen(
PluviaApp.isOverlayPaused = false
}
registerBackAction { }
+ registerQuickMenuToggle?.invoke {}
} // preserve suspend state across activity recreation while a game is still running
}
@@ -2628,6 +2683,14 @@ fun XServerScreen(
onLsfgMultiplierChanged = ::applyLsfgMultiplier,
onLsfgFlowScaleChanged = ::applyLsfgFlowScale,
onLsfgPerformanceModeChanged = ::applyLsfgPerformanceMode,
+ // Immersive tab (tab only visible when hosted by ImmersiveXrActivity)
+ immersiveControls = immersiveControls,
+ registerFocusManager = registerQuickMenuFocusManager,
+ registerCycleTab = registerQuickMenuCycleTab ?: {},
+ registerAdjustmentControl = registerQuickMenuAdjustmentControl ?: {},
+ registerFocusTabRail = registerQuickMenuFocusTabRail ?: {},
+ registerFocusedActivate = registerQuickMenuFocusedActivate ?: {},
+ registerStartHeld = registerQuickMenuStartHeld ?: {},
onAnimationComplete = { isMenuVisible ->
if (isMenuVisible) {
pauseForOverlayIfAllowed()
@@ -2654,6 +2717,13 @@ fun XServerScreen(
}
if (manualResumeMode && PluviaApp.isOverlayPaused && !showQuickMenu && !keepPausedForEditor) {
+ val resumeButtonFocusRequester = remember { FocusRequester() }
+ // Not focusable by default touch-only flows don't need it, but a gamepad's
+ // DPAD_CENTER only activates a clickable() that currently has focus — request it
+ // as soon as this button appears so it's reachable without a prior focus target.
+ LaunchedEffect(Unit) {
+ runCatching { resumeButtonFocusRequester.requestFocus() }
+ }
Box(
modifier = Modifier
.fillMaxSize()
@@ -2671,6 +2741,8 @@ fun XServerScreen(
color = androidx.compose.ui.graphics.Color.White,
shape = androidx.compose.foundation.shape.CircleShape,
)
+ .focusRequester(resumeButtonFocusRequester)
+ .focusable()
.clickable(onClick = ::resumeFromManualButton),
contentAlignment = Alignment.Center,
) {
diff --git a/app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt b/app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt
new file mode 100644
index 0000000000..41040b3cbe
--- /dev/null
+++ b/app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt
@@ -0,0 +1,226 @@
+package app.gamenative.utils
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Environment
+import androidx.activity.ComponentActivity
+import androidx.activity.result.contract.ActivityResultContracts
+import app.gamenative.service.SteamService
+import kotlinx.coroutines.suspendCancellableCoroutine
+import timber.log.Timber
+import java.io.File
+import kotlin.coroutines.resume
+
+/**
+ * Installs/launches the native Android build of a game (Steam Frame / Lepton depot),
+ * bypassing the Wine/Winlator container entirely.
+ */
+object AndroidGameLauncher {
+
+ private const val TAG = "AndroidGameLauncher"
+
+ // Depot content for an Android build is expected to be shallow (apk + an obb/ folder at
+ // most); bound the walk so a huge, deeply-nested Workshop/DLC tree can't make this slow.
+ private const val MAX_SEARCH_DEPTH = 6
+
+ private fun findApkFile(gameId: Int): File? {
+ val dir = File(SteamService.getAppDirPath(gameId))
+ if (!dir.exists()) return null
+ return dir.walkTopDown().maxDepth(MAX_SEARCH_DEPTH)
+ .firstOrNull { it.isFile && it.extension.equals("apk", ignoreCase = true) }
+ }
+
+ private fun findObbFiles(gameId: Int): List {
+ val dir = File(SteamService.getAppDirPath(gameId))
+ if (!dir.exists()) return emptyList()
+ return dir.walkTopDown().maxDepth(MAX_SEARCH_DEPTH)
+ .filter { it.isFile && it.extension.equals("obb", ignoreCase = true) }.toList()
+ }
+
+ private fun getApkPackageName(context: Context, apk: File): String? {
+ return try {
+ context.packageManager.getPackageArchiveInfo(apk.absolutePath, 0)?.packageName
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Could not read package info from ${apk.absolutePath}")
+ null
+ }
+ }
+
+ /** Unwraps a possibly-wrapped Context (e.g. a ContextWrapper) to find the backing Activity. */
+ private tailrec fun Context.findActivity(): ComponentActivity? = when (this) {
+ is ComponentActivity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+ }
+
+ private fun isPackageInstalled(context: Context, packageName: String): Boolean {
+ return try {
+ context.packageManager.getPackageInfo(packageName, 0)
+ true
+ } catch (e: PackageManager.NameNotFoundException) {
+ false
+ }
+ }
+
+ /**
+ * Best-effort OBB placement under /Android/obb//. On Android 11+ this requires
+ * broad ("All files access") storage permission GameNative doesn't currently request, so
+ * this silently no-ops there — games that need their OBB will fail to start until that's added.
+ */
+ private fun copyObbFiles(gameId: Int, packageName: String) {
+ val obbFiles = findObbFiles(gameId)
+ if (obbFiles.isEmpty()) return
+ try {
+ val obbRoot = File(Environment.getExternalStorageDirectory(), "Android/obb/$packageName")
+ obbRoot.mkdirs()
+ obbFiles.forEach { src -> src.copyTo(File(obbRoot, src.name), overwrite = true) }
+ Timber.tag(TAG).i("Copied ${obbFiles.size} OBB file(s) to ${obbRoot.absolutePath}")
+ } catch (e: Exception) {
+ Timber.tag(TAG).w(e, "Could not place OBB files for $packageName (needs broader storage access on Android 11+)")
+ }
+ }
+
+ private const val STAGING_DIR_NAME = "android_game_installs"
+
+ /**
+ * Copies the downloaded .apk into the app's own cache dir so FileProvider only ever needs to
+ * grant access to a narrow, app-controlled location (already covered by the existing
+ * cache-path entry) instead of the whole install-directory tree.
+ */
+ private fun stageApkForInstall(context: Context, gameId: Int, apk: File): File? {
+ return try {
+ val stagingDir = File(context.cacheDir, STAGING_DIR_NAME).apply { mkdirs() }
+ val staged = File(stagingDir, "$gameId.apk")
+ apk.copyTo(staged, overwrite = true)
+ staged
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Could not stage APK for install from ${apk.absolutePath}")
+ null
+ }
+ }
+
+ /**
+ * Removes this game's staged install copy, if any. Each staged .apk is only overwritten by a
+ * later install of the *same* game, so without this, every distinct Android game ever
+ * installed leaves a permanent copy behind in the cache dir. Safe to call any time, including
+ * when nothing was ever staged.
+ */
+ fun cleanupStagedApk(context: Context, gameId: Int) {
+ val staged = File(File(context.cacheDir, STAGING_DIR_NAME), "$gameId.apk")
+ if (staged.exists() && !staged.delete()) {
+ Timber.tag(TAG).w("Could not delete staged APK ${staged.absolutePath}")
+ }
+ }
+
+ /** Package name of this game's downloaded .apk, or null if none is on disk / unreadable. */
+ fun resolvePackageName(context: Context, gameId: Int): String? {
+ val apk = findApkFile(gameId) ?: return null
+ return getApkPackageName(context, apk)
+ }
+
+ /** Whether the Android app for this game is actually installed on the system (not just downloaded). */
+ fun isGameInstalled(context: Context, gameId: Int): Boolean {
+ val packageName = resolvePackageName(context, gameId) ?: return false
+ return isPackageInstalled(context, packageName)
+ }
+
+ sealed class Result {
+ data object Launched : Result()
+ data object InstallStarted : Result()
+ data object Failed : Result()
+ }
+
+ /**
+ * If the downloaded APK is already installed, launches it directly. Otherwise kicks off
+ * the system install flow (user confirmation required) — the caller must ask the user to
+ * press Play again once that finishes; there is no reliable synchronous "wait for install".
+ */
+ fun installAndLaunch(context: Context, gameId: Int): Result {
+ val apk = findApkFile(gameId)
+ if (apk == null) {
+ Timber.tag(TAG).e("No APK found on disk for game $gameId")
+ return Result.Failed
+ }
+ val packageName = getApkPackageName(context, apk)
+ if (packageName == null) {
+ Timber.tag(TAG).e("Could not resolve package name from ${apk.absolutePath}")
+ return Result.Failed
+ }
+ if (!isPackageInstalled(context, packageName)) {
+ copyObbFiles(gameId, packageName)
+ val staged = stageApkForInstall(context, gameId, apk) ?: return Result.Failed
+ return if (UpdateInstaller.installApk(context, staged)) Result.InstallStarted else Result.Failed
+ }
+ val intent = context.packageManager.getLaunchIntentForPackage(packageName)
+ ?: return Result.Failed
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ return Result.Launched
+ }
+
+ /**
+ * Prompts the system uninstall dialog for the Android package matching this game (if one is
+ * actually installed) and suspends until the dialog is dismissed. Uses
+ * ACTION_UNINSTALL_PACKAGE + EXTRA_RETURN_RESULT so the result (confirmed vs. cancelled) comes
+ * back immediately as an activity result, instead of waiting on an ACTION_PACKAGE_REMOVED
+ * broadcast that a cancelled dialog never sends — that previously left the caller's "deleting"
+ * UI stuck for a long fixed timeout on every cancel.
+ *
+ * Returns true when it's safe for the caller to delete the downloaded .apk / GameNative's own
+ * install record: either nothing was installed to begin with, or the system confirmed removal.
+ * Returns false if the user cancelled (or context isn't an Activity capable of launching for a
+ * result), so the caller must NOT delete anything in that case — otherwise the app would be
+ * left installed with no way left to resolve its package name.
+ *
+ * Must be called before the downloaded .apk is deleted from disk (needed to resolve the
+ * package name in the first place).
+ */
+ suspend fun requestUninstall(context: Context, gameId: Int): Boolean {
+ val apk = findApkFile(gameId)
+ if (apk == null) {
+ Timber.tag(TAG).w("requestUninstall: no APK found on disk for game $gameId, skipping system uninstall")
+ return true
+ }
+ val packageName = getApkPackageName(context, apk)
+ if (packageName == null) {
+ Timber.tag(TAG).w("requestUninstall: could not resolve package name from ${apk.absolutePath}")
+ return true
+ }
+ if (!isPackageInstalled(context, packageName)) {
+ Timber.tag(TAG).i("requestUninstall: package $packageName is not currently installed, nothing to do")
+ return true
+ }
+ val activity = context.findActivity()
+ if (activity == null) {
+ Timber.tag(TAG).e("requestUninstall: context is not a ComponentActivity, cannot prompt for a result")
+ return false
+ }
+
+ Timber.tag(TAG).i("requestUninstall: prompting system uninstall for $packageName")
+ return suspendCancellableCoroutine { cont ->
+ lateinit var launcher: androidx.activity.result.ActivityResultLauncher
+ launcher = activity.activityResultRegistry.register(
+ "android_game_uninstall_$gameId",
+ ActivityResultContracts.StartActivityForResult(),
+ ) { result ->
+ launcher.unregister()
+ if (cont.isActive) cont.resume(result.resultCode == Activity.RESULT_OK)
+ }
+ cont.invokeOnCancellation { runCatching { launcher.unregister() } }
+
+ val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, Uri.parse("package:$packageName"))
+ .putExtra(Intent.EXTRA_RETURN_RESULT, true)
+ try {
+ launcher.launch(intent)
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Could not launch system uninstall for $packageName")
+ launcher.unregister()
+ if (cont.isActive) cont.resume(false)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt
index 476fdbb062..2bba257d47 100644
--- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt
+++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt
@@ -328,6 +328,7 @@ object ContainerUtils {
box64Preset = container.box64Preset,
desktopTheme = container.desktopTheme,
containerVariant = container.containerVariant,
+ platform = container.platform,
wineVersion = container.wineVersion,
emulator = container.emulator,
fexcoreVersion = container.fexCoreVersion,
@@ -521,6 +522,7 @@ object ContainerUtils {
container.desktopTheme = containerData.desktopTheme
container.graphicsDriverVersion = containerData.graphicsDriverVersion
container.containerVariant = containerData.containerVariant
+ container.platform = containerData.platform
container.wineVersion = containerData.wineVersion
container.emulator = containerData.emulator
container.fexCoreVersion = containerData.fexcoreVersion
@@ -1082,8 +1084,12 @@ object ContainerUtils {
return if (containerManager.hasContainer(appId)) {
val container = containerManager.getContainerById(appId)
- // Apply temporary override if present (without saving to disk)
- if (IntentLaunchManager.hasTemporaryOverride(appId)) {
+ // Temporary overrides only carry Wine/Winlator settings (graphics driver, dxwrapper,
+ // etc.) and applyToContainer() unconditionally writes to .wine/user.reg — a native
+ // Android container never has a .wine folder, so skip overrides entirely there rather
+ // than risk that write failing before AndroidGameLauncher ever runs.
+ val isAndroidContainer = container.platform.equals(Container.PLATFORM_ANDROID, ignoreCase = true)
+ if (!isAndroidContainer && IntentLaunchManager.hasTemporaryOverride(appId)) {
val overrideConfig = IntentLaunchManager.getTemporaryOverride(appId)
if (overrideConfig != null) {
// Backup original config before applying override (if not already backed up)
diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt
index 3955d16d4c..773aa1d494 100644
--- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt
+++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt
@@ -114,6 +114,7 @@ object SteamUtils {
ownedDlc: Map?,
licensedDepotIds: Set?,
hasSteamUnlockedBranch: Boolean = false,
+ wantAndroid: Boolean = false,
): String {
// A depot installs once its language is chosen if it passes every check except language
// and arch. Arch is left out because it is a per-language preference, not a gate.
@@ -126,7 +127,8 @@ object SteamUtils {
licensedDepotIds == null || depotId in licensedDepotIds
// Mirror the SteamChina realm gate in filterForDownloadableDepots, or we could pick a
// language only the final pass drops and lose the real fallback.
- return isWindowsCompatible && realm != SteamRealm.SteamChina &&
+ val osCompatible = if (wantAndroid) isAndroidCompatible else isWindowsCompatible
+ return osCompatible && realm != SteamRealm.SteamChina &&
hasContent && ownedIfDlc && licensedIfBaseGame
}
diff --git a/app/src/main/java/app/gamenative/utils/UpdateInstaller.kt b/app/src/main/java/app/gamenative/utils/UpdateInstaller.kt
index 7b06e222fe..0c752537b8 100644
--- a/app/src/main/java/app/gamenative/utils/UpdateInstaller.kt
+++ b/app/src/main/java/app/gamenative/utils/UpdateInstaller.kt
@@ -53,23 +53,24 @@ object UpdateInstaller {
Timber.i("Download complete: ${destFile.absolutePath}, size: $fileSize bytes")
// Install the APK
- withContext(Dispatchers.Main) {
+ val installStarted = withContext(Dispatchers.Main) {
installApk(context, destFile)
}
- return@withContext true
+ return@withContext installStarted
} catch (e: Exception) {
Timber.e(e, "Error downloading/installing update")
return@withContext false
}
}
- private fun installApk(context: Context, apkFile: File) {
+ /** Returns true only if the system install intent was actually launched. */
+ fun installApk(context: Context, apkFile: File): Boolean {
try {
// Verify file exists before attempting installation
if (!apkFile.exists()) {
Timber.e("APK file does not exist: ${apkFile.absolutePath}")
- return
+ return false
}
Timber.i("Installing APK from: ${apkFile.absolutePath}, size: ${apkFile.length()} bytes")
@@ -84,7 +85,7 @@ object UpdateInstaller {
)
} catch (e: Exception) {
Timber.e(e, "Error getting FileProvider URI")
- return
+ return false
}
} else {
// Use file:// URI for older versions
@@ -114,8 +115,10 @@ object UpdateInstaller {
context.startActivity(intent)
Timber.i("Install intent launched successfully")
+ return true
} catch (e: Exception) {
Timber.e(e, "Error launching install intent")
+ return false
}
}
}
diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java
index 86d3b1778f..ecf86e7df8 100644
--- a/app/src/main/java/com/winlator/container/Container.java
+++ b/app/src/main/java/com/winlator/container/Container.java
@@ -72,6 +72,10 @@ public enum XrControllerMapping {
public static final String STEAM_TYPE_ULTRALIGHT = "ultralight";
public static final String GLIBC = "glibc";
public static final String BIONIC = "bionic";
+
+ // Which Steam depot platform to download/install for this game.
+ public static final String PLATFORM_WINDOWS = "windows";
+ public static final String PLATFORM_ANDROID = "android";
public static final byte MAX_DRIVE_LETTERS = 8;
public final String id;
private String name;
@@ -90,6 +94,7 @@ public enum XrControllerMapping {
private String drives = DEFAULT_DRIVES;
private String wineVersion = WineInfo.MAIN_WINE_VERSION.identifier();
private boolean showFPS;
+ private boolean launchImmersiveMode;
private boolean launchRealSteam;
private boolean launchBionicSteam;
private boolean allowSteamUpdates;
@@ -168,6 +173,8 @@ public enum XrControllerMapping {
private String containerVariant = DEFAULT_VARIANT;
+ private String platform = PLATFORM_WINDOWS;
+
public String getGraphicsDriverVersion() {
return graphicsDriverVersion;
}
@@ -348,6 +355,14 @@ public void setShowFPS(boolean showFPS) {
this.showFPS = showFPS;
}
+ public boolean isLaunchImmersiveMode() {
+ return launchImmersiveMode;
+ }
+
+ public void setLaunchImmersiveMode(boolean launchImmersiveMode) {
+ this.launchImmersiveMode = launchImmersiveMode;
+ }
+
public boolean isLaunchRealSteam() {
return launchRealSteam;
}
@@ -512,6 +527,14 @@ public String getContainerVariant() {
return this.containerVariant;
}
+ public void setPlatform(String platform) {
+ this.platform = platform != null && !platform.isEmpty() ? platform : PLATFORM_WINDOWS;
+ }
+
+ public String getPlatform() {
+ return this.platform;
+ }
+
public String getExtra(String name) {
return getExtra(name, "");
}
@@ -694,6 +717,7 @@ public void saveData() {
data.put("wincomponents", wincomponents);
data.put("drives", drives);
data.put("showFPS", showFPS);
+ data.put("launchImmersiveMode", launchImmersiveMode);
data.put("launchRealSteam", launchRealSteam);
data.put("launchBionicSteam", launchBionicSteam);
data.put("allowSteamUpdates", allowSteamUpdates);
@@ -739,6 +763,7 @@ public void saveData() {
data.put("steamType", steamType);
data.put("language", language);
data.put("containerVariant", containerVariant);
+ data.put("platform", platform);
data.put("emulator", emulator);
data.put("fexcoreVersion", fexcoreVersion);
@@ -828,6 +853,9 @@ public void loadData(JSONObject data) throws JSONException {
case "showFPS" :
setShowFPS(data.getBoolean(key));
break;
+ case "launchImmersiveMode" :
+ setLaunchImmersiveMode(data.getBoolean(key));
+ break;
case "launchRealSteam" :
setLaunchRealSteam(data.getBoolean(key));
break;
@@ -846,6 +874,9 @@ public void loadData(JSONObject data) throws JSONException {
case "containerVariant" :
setContainerVariant(data.getString(key));
break;
+ case "platform" :
+ setPlatform(data.getString(key));
+ break;
case "inputType" :
setInputType(data.getInt(key));
break;
diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt
index 9c8e339c09..e689b21e96 100644
--- a/app/src/main/java/com/winlator/container/ContainerData.kt
+++ b/app/src/main/java/com/winlator/container/ContainerData.kt
@@ -44,6 +44,8 @@ data class ContainerData(
val desktopTheme: String = WineThemeManager.DEFAULT_DESKTOP_THEME,
// container runtime variant (glibc or bionic)
val containerVariant: String = Container.DEFAULT_VARIANT,
+ // which Steam depot platform to download/install (windows or android)
+ val platform: String = Container.PLATFORM_WINDOWS,
// wine version identifier (used for bionic variant), defaults to main wine
val wineVersion: String = WineInfo.MAIN_WINE_VERSION.identifier(),
// selected 32-bit emulator for WoW64 processes (FEXCore/Box64)
@@ -144,6 +146,7 @@ data class ContainerData(
"box64Preset" to state.box64Preset,
"desktopTheme" to state.desktopTheme,
"containerVariant" to state.containerVariant,
+ "platform" to state.platform,
"wineVersion" to state.wineVersion,
"emulator" to state.emulator,
"fexcoreVersion" to state.fexcoreVersion,
@@ -214,6 +217,7 @@ data class ContainerData(
box64Preset = savedMap["box64Preset"] as String,
desktopTheme = savedMap["desktopTheme"] as String,
containerVariant = (savedMap["containerVariant"] as? String) ?: Container.DEFAULT_VARIANT,
+ platform = (savedMap["platform"] as? String) ?: Container.PLATFORM_WINDOWS,
wineVersion = (savedMap["wineVersion"] as? String) ?: WineInfo.MAIN_WINE_VERSION.identifier(),
emulator = (savedMap["emulator"] as? String) ?: Container.DEFAULT_EMULATOR,
fexcoreVersion = (savedMap["fexcoreVersion"] as? String) ?: DefaultVersion.FEXCORE,
diff --git a/app/src/main/java/com/winlator/container/ContainerManager.java b/app/src/main/java/com/winlator/container/ContainerManager.java
index 1ee9538815..2f029127be 100644
--- a/app/src/main/java/com/winlator/container/ContainerManager.java
+++ b/app/src/main/java/com/winlator/container/ContainerManager.java
@@ -225,6 +225,7 @@ private void duplicateContainer(Container srcContainer) {
dstContainer.setDesktopTheme(srcContainer.getDesktopTheme());
dstContainer.setRcfileId(srcContainer.getRCFileId());
dstContainer.setWineVersion(srcContainer.getWineVersion());
+ dstContainer.setPlatform(srcContainer.getPlatform());
dstContainer.saveData();
containers.add(dstContainer);
diff --git a/app/src/main/java/com/winlator/renderer/GLHardwareBufferImporter.java b/app/src/main/java/com/winlator/renderer/GLHardwareBufferImporter.java
new file mode 100644
index 0000000000..11054d523c
--- /dev/null
+++ b/app/src/main/java/com/winlator/renderer/GLHardwareBufferImporter.java
@@ -0,0 +1,26 @@
+package com.winlator.renderer;
+
+import android.hardware.HardwareBuffer;
+
+/**
+ * Imports an {@link HardwareBuffer} as a GL texture usable in the *calling thread's* current EGL
+ * context — the public Android SDK has no clean Java binding for the
+ * eglGetNativeClientBufferANDROID / eglCreateImageKHR / glEGLImageTargetTexture2DOES sequence
+ * this needs (GLES11Ext's binding still takes a raw java.nio.Buffer, a legacy pre-HardwareBuffer
+ * calling convention), so this is a thin JNI call into the same native library the Quest
+ * immersive session already uses (libxrimmersive.so) rather than anything renderer-specific.
+ *
+ * Must be called from a thread with a current EGL context (e.g. GLRenderer's own GL thread,
+ * from inside onDrawFrame/queueEvent) — the resulting texture is only valid in that context.
+ */
+public final class GLHardwareBufferImporter {
+ static {
+ System.loadLibrary("xrimmersive");
+ }
+
+ private GLHardwareBufferImporter() {}
+
+ /** Returns a GL texture name bound (GL_TEXTURE_2D) to the given buffer's contents in the
+ * calling thread's current context, or 0 on failure. */
+ public static native int importAsTexture(HardwareBuffer buffer);
+}
diff --git a/app/src/main/java/com/winlator/renderer/GLRenderer.java b/app/src/main/java/com/winlator/renderer/GLRenderer.java
index b397fa28f0..8833eb4489 100644
--- a/app/src/main/java/com/winlator/renderer/GLRenderer.java
+++ b/app/src/main/java/com/winlator/renderer/GLRenderer.java
@@ -61,6 +61,12 @@ public class GLRenderer implements GLSurfaceView.Renderer, WindowManager.OnWindo
private boolean sceneInitialized = false;
private final EffectComposer effectComposer;
private FrameRating frameRating;
+ // Null on every launch except the Meta Quest immersive path — see XrFrameBridge's kdoc.
+ // volatile: set from a background capture thread, read from the GL render thread — see the
+ // identical field in VulkanRenderer for why a plain field is a real cross-thread visibility
+ // bug here, not just a theoretical one (confirmed: the equivalent Vulkan bridge never fired
+ // despite attaching successfully, until this was added there).
+ private volatile XrFrameBridge xrFrameBridge = null;
public GLRenderer(XServerViewGL xServerView, XServer xServer) {
this.xServerView = xServerView;
@@ -148,8 +154,20 @@ public void onDrawFrame(GL10 gl) {
}
void drawScene() {
+ // xrFrameBridge is null on every launch except the Quest immersive path (see
+ // XrFrameBridge's kdoc) — beginFrame() binds its own FBO (wrapping a GPU buffer the
+ // immersive session samples directly, no CPU readback needed) and reports the
+ // resolution to render at; null means it didn't bind anything, so rendering proceeds
+ // into whatever's already bound (the SurfaceView's own surface) exactly as if this
+ // bridge didn't exist at all.
boolean xrFrame = false;
- // if (XrActivity.isSupported()) xrFrame = XrActivity.getInstance().beginFrame(XrActivity.getImmersive(), XrActivity.getSBS());
+ if (xrFrameBridge != null) {
+ int[] xrTargetSize = xrFrameBridge.beginFrame();
+ if (xrTargetSize != null) {
+ xrFrame = true;
+ setRenderTargetSizeOverride(xrTargetSize[0], xrTargetSize[1]);
+ }
+ }
int targetWidth = renderTargetWidthOverride > 0 ? renderTargetWidthOverride : surfaceWidth;
int targetHeight = renderTargetHeightOverride > 0 ? renderTargetHeightOverride : surfaceHeight;
@@ -222,8 +240,10 @@ else if (magnifierEnabled) {
if ((!magnifierEnabled && !fullscreen) || renderingToOffscreenTarget) GLES20.glDisable(GLES20.GL_SCISSOR_TEST);
if (xrFrame) {
- // XrActivity.getInstance().endFrame();
- // XrActivity.updateControllers();
+ xrFrameBridge.endFrame();
+ // GLSurfaceView normally only redraws when something changes (RENDERMODE_WHEN_DIRTY)
+ // — the immersive session needs a continuous stream of frames regardless, same as
+ // GameNativeXR drives its own render loop this way.
xServerView.requestRender();
}
}
@@ -341,6 +361,13 @@ public void setOnFrameRenderedListener(Runnable onFrameRenderedListener) {
this.onFrameRenderedListener = onFrameRenderedListener;
}
+ /** See {@link XrFrameBridge}. Pass null to detach (e.g. when leaving immersive mode). */
+ public void setXrFrameBridge(XrFrameBridge xrFrameBridge) {
+ this.xrFrameBridge = xrFrameBridge;
+ viewportNeedsUpdate = true;
+ xServerView.requestRender();
+ }
+
private Drawable createRootCursorDrawable() {
Context context = xServerView.getContext();
BitmapFactory.Options options = new BitmapFactory.Options();
diff --git a/app/src/main/java/com/winlator/renderer/VulkanRenderer.java b/app/src/main/java/com/winlator/renderer/VulkanRenderer.java
index 14f2e857a3..0b2cbb6082 100644
--- a/app/src/main/java/com/winlator/renderer/VulkanRenderer.java
+++ b/app/src/main/java/com/winlator/renderer/VulkanRenderer.java
@@ -66,6 +66,16 @@ public class VulkanRenderer implements WindowManager.OnWindowModificationListene
private Drawable rootCursorDrawable;
private Cursor lastCursor = null;
private boolean xRenderingPausedForScanout = false;
+ // volatile: set from ImmersiveXrActivity's own background capture thread, read from
+ // whichever thread drives onUpdateWindowContentDirect (a real gamepad/game-rendering
+ // thread, not that capture thread) — without this the write may never become visible on the
+ // other thread at all (the JIT is free to cache the field's value once the read loop is hot).
+ private volatile VulkanXrFrameBridge xrFrameBridge = null;
+
+ /** See VulkanXrFrameBridge's kdoc — null except for the Meta Quest immersive path. */
+ public void setVulkanXrFrameBridge(VulkanXrFrameBridge xrFrameBridge) {
+ this.xrFrameBridge = xrFrameBridge;
+ }
private volatile ArrayList renderableWindows = new ArrayList<>();
private static final java.util.concurrent.atomic.AtomicLong ID_GEN =
@@ -450,6 +460,15 @@ public void onUpdateWindowContentDirect(Window window, Drawable pixmap, short xO
rx, ry, pixmap.width, pixmap.height, fenceFd);
g.lock();
} else {
+ // THIS is the method PresentExtension actually calls for every real
+ // DXVK/Vulkan frame present (see PresentExtension.java's pixmapPresent —
+ // it routes VulkanRenderer specifically to onUpdateWindowContentDirect,
+ // not onUpdateWindowContent, which an earlier version of this hook was
+ // wrongly placed in and confirmed via logging to never be called at all
+ // for this renderer/path). This is the one live per-frame call site.
+ if (xrFrameBridge != null) {
+ xrFrameBridge.onScanoutBuffer(ahbPtr, pixmap.width, pixmap.height);
+ }
nativeUpdateWindowContentAHB(nativeHandle, targetId, ahbPtr,
pixmap.width, pixmap.height, rx, ry);
}
@@ -506,6 +525,11 @@ public void onUpdateWindowContent(Window window) {
xRenderingPausedForScanout = true;
}
} else if (!scanoutNow) {
+ // No XR bridge hook here (an earlier version had one) — confirmed via
+ // logging that this method is never even called for a real DXVK/Vulkan
+ // game: PresentExtension routes frame presents for VulkanRenderer to
+ // onUpdateWindowContentDirect below instead (see its own kdoc), not here.
+ // This one only runs for other WindowManager listener callbacks.
nativeUpdateWindowContentAHB(handle, drawableId, ahbPtr,
drawable.width, drawable.height, rx, ry);
}
@@ -754,6 +778,19 @@ private boolean computeEffectsRequireCompositor() {
|| Math.abs(pendingGamma - 1.0f) > 1e-3f
|| pendingFilterMode != 0;
}
+
+ /** Whether any active effect/filter/color adjustment is currently forcing the compositor
+ * path, blocking the zero-copy scanout fast-path. See {@link #resetScreenEffects()}. */
+ public boolean isEffectsRequireCompositor() { return effectsRequireCompositor; }
+
+ /** Turns off every effect, filter, and color adjustment (back to defaults), letting scanout
+ * re-establish itself if nothing else is blocking it. Used by the immersive quick-menu tab's
+ * "reset" action — screen effects are the single most common reason a user would otherwise
+ * need to hunt through several unrelated settings to get the zero-copy path back. */
+ public void resetScreenEffects() {
+ setFilterMode(0);
+ setEffect(EFFECT_NONE, 0.0f, SCALE_FIT, 0, 0.0f, 0.0f, 1.0f);
+ }
public int getEffectId() { return pendingEffectId; }
public float getSharpness() { return pendingSharpness; }
diff --git a/app/src/main/java/com/winlator/renderer/VulkanXrFrameBridge.java b/app/src/main/java/com/winlator/renderer/VulkanXrFrameBridge.java
new file mode 100644
index 0000000000..6c5f9dab8c
--- /dev/null
+++ b/app/src/main/java/com/winlator/renderer/VulkanXrFrameBridge.java
@@ -0,0 +1,31 @@
+package com.winlator.renderer;
+
+/**
+ * Null except for the Meta Quest immersive path (see ImmersiveXrActivity's DirectVulkanBridge).
+ * VulkanRenderer already turns the game's rendered frame into an AHardwareBuffer on every frame
+ * (see onUpdateWindowContentDirect's nativeUpdateWindowContentAHB call) — this hook observes
+ * that exact same buffer pointer, at the exact same call site, so the OpenXR session can import
+ * it too instead of falling back to PixelCopy screen-scraping. VulkanRenderer's own behavior is
+ * unchanged whether or not a bridge is attached.
+ *
+ * Two things had to be found and fixed before this actually fired for a real game, in order:
+ * 1. Named onScanoutBuffer for historical reasons — it was originally attached to
+ * VulkanRenderer's "zero-copy scanout" path (nativeScanoutSetBuffer), which turned out to be
+ * unreachable: {@code Drawable.isDirectScanout()} is hardcoded false and never overridden
+ * anywhere in this codebase, so that branch never executes.
+ * 2. Moved to the AHB-upload branch instead (nativeUpdateWindowContentAHB) — but in the WRONG
+ * method at first ({@code onUpdateWindowContent}, called via the generic WindowManager
+ * listener path). Confirmed via logging that method is never even invoked for a real
+ * DXVK/Vulkan game at all: {@code PresentExtension}'s pixmapPresent routes VulkanRenderer
+ * specifically to {@code onUpdateWindowContentDirect} for every frame present instead — that
+ * is the one actually-live call site this hook now lives in.
+ */
+public interface VulkanXrFrameBridge {
+ /**
+ * ahbPtr is the same raw AHardwareBuffer* (as a jlong) VulkanRenderer is handing to
+ * nativeUpdateWindowContentAHB for on-screen presentation — read-only, do not release/free it.
+ * Called on every real frame update for a Vulkan-rendered window (see this interface's kdoc
+ * for why "scanout" in the name is no longer literally accurate).
+ */
+ void onScanoutBuffer(long ahbPtr, int width, int height);
+}
diff --git a/app/src/main/java/com/winlator/renderer/XrFrameBridge.java b/app/src/main/java/com/winlator/renderer/XrFrameBridge.java
new file mode 100644
index 0000000000..e5151d88c0
--- /dev/null
+++ b/app/src/main/java/com/winlator/renderer/XrFrameBridge.java
@@ -0,0 +1,27 @@
+package com.winlator.renderer;
+
+/**
+ * Optional collaborator for {@link GLRenderer}, set only by the Meta Quest immersive launch path
+ * (see app.gamenative.ui.screen.xr) — null (the default) for every other launch, on every other
+ * platform, with zero behavior change to GLRenderer's normal rendering.
+ *
+ * The point of this is to let the game render directly into a GPU buffer that the immersive
+ * session's OpenXR quad can sample from, instead of screen-scraping the finished frame back off
+ * the SurfaceView (PixelCopy) — which was measurably the dominant cost of that pipeline (a GPU
+ * readback plus multiple full-frame CPU memory copies, every frame). GLRenderer keeps rendering
+ * exactly the same draw calls either way; only the framebuffer it targets changes.
+ */
+public interface XrFrameBridge {
+ /**
+ * Called at the very start of drawScene(), before any GL state is touched for this frame.
+ * Implementations bind their own target framebuffer (wrapping a shared GPU buffer) here.
+ * Returns the [width, height] GLRenderer should render at this frame, or null if the bridge
+ * isn't ready yet (e.g. the shared buffer hasn't been set up) — GLRenderer falls back to
+ * rendering into its own surface as normal for that frame.
+ */
+ int[] beginFrame();
+
+ /** Called right after GLRenderer finishes issuing draw calls for this frame (still with the
+ * bridge's framebuffer bound) — implementations do any per-frame teardown/flush here. */
+ void endFrame();
+}
diff --git a/app/src/main/jniLibs/arm64-v8a/libxrimmersive.so b/app/src/main/jniLibs/arm64-v8a/libxrimmersive.so
new file mode 100755
index 0000000000..7ea189e2d1
Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libxrimmersive.so differ
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 159a162b9c..60c9d19a1e 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -76,6 +76,7 @@
Branche débloquée !
Code d\'accès invalide
Jouer
+ Lancer en mode immersif (VR)
Installer l\'application
Calcul des besoins en espace…
Supprimer l\'application
@@ -310,6 +311,19 @@
Fermer
Général
Contrôles
+ Immersif
+ Passthrough
+ Voir ton environnement réel à travers l\'écran virtuel
+ Activer le passthrough
+ Poignées de redimensionnement / déplacement
+ Affiche des poignées juste à l\'intérieur des bords du jeu quand c\'est activé — désactivé par défaut, pour garder l\'image nette sans marge autour.
+ Afficher les poignées
+ Performance
+ Un effet, filtre ou réglage contraste/luminosité/gamma actif bloque le mode de rendu le plus rapide.
+ Rien ne bloque actuellement le mode de rendu le plus rapide.
+ Réinitialiser les effets d\'écran
+ Désactive tous les effets, filtres et réglages contraste/luminosité/gamma. Effet immédiat — aucun redémarrage nécessaire.
+ Désactive le passthrough pour de meilleures performances
HUD de performance
Afficher ou masquer la surcouche en jeu.
Limiteur de FPS
@@ -646,6 +660,14 @@
Valeur
Plus de variables connues
Variante du conteneur
+ Version
+ Choisissez de télécharger la version Windows (via Wine/Proton) ou la version Android native, lorsque le jeu la propose.
+ Normale (Windows)
+ Android
+ Changement de version — suppression de l\'installation actuelle et récupération de la nouvelle…
+ Installation de la version Android — appuyez de nouveau sur Jouer une fois l\'installation terminée.
+ Impossible d\'installer ou de lancer la version Android de ce jeu.
+ Désinstallation annulée — l\'application est toujours installée, rien n\'a été supprimé.
Version Wine
Arguments d\'exécution
Exemple : -dx11
@@ -1621,6 +1643,7 @@
Avis 5 étoiles sur l\'appareil
Avis 5 étoiles sur le GPU
Éprouvé sur votre GPU
+ Version Android disponible
Gérer les mods Nexus
Effacer la recherche
Tout
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2305d3b723..2f8b091744 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -85,6 +85,7 @@
Branch unlocked!
Invalid access code
Play
+ Launch in immersive mode (VR)
Install App
Calculating space requirements...
Delete App
@@ -307,6 +308,19 @@
Close
General
Controller
+ Immersive
+ Passthrough
+ See your real surroundings through the virtual screen
+ Enable passthrough
+ Resize / move handles
+ Shows grab handles just inside the game\'s own edges while on — off by default, since keeping the screen border-free avoids losing sharpness to a margin.
+ Show resize/move handles
+ Performance
+ An active screen effect, filter, or brightness/contrast/gamma adjustment is blocking the fastest rendering path.
+ Nothing is currently blocking the fastest rendering path.
+ Reset screen effects
+ Turns off every effect, filter, and brightness/contrast/gamma adjustment. Takes effect immediately — no restart needed.
+ Disable passthrough for better performance
%1$d running, tap/click a process to close
No running EXEs detected.
Performance HUD
@@ -726,6 +740,7 @@
5-star reviews on device
5-star reviews on GPU
Proven on your GPU
+ Android version available
Connecting to Steam…
@@ -767,6 +782,14 @@
Value
No more known variables
Container Variant
+ Version
+ Choose whether to download the Windows version (runs via Wine/Proton) or the native Android version, when the game offers one.
+ Normal (Windows)
+ Android
+ Switching version — removing the current install and fetching the new one…
+ Installing the Android version — press Play again once it\'s installed.
+ Couldn\'t install or launch the Android version of this game.
+ Uninstall was cancelled — the app is still installed, nothing was deleted.
Wine Version
Exec Arguments
Example: -dx11
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml
index 90f2ff36a3..cf58fd023e 100644
--- a/app/src/main/res/xml/file_provider_paths.xml
+++ b/app/src/main/res/xml/file_provider_paths.xml
@@ -3,5 +3,8 @@
+
diff --git a/app/src/modern/AndroidManifest.xml b/app/src/modern/AndroidManifest.xml
index 8a762a288f..35a5e688ab 100644
--- a/app/src/modern/AndroidManifest.xml
+++ b/app/src/modern/AndroidManifest.xml
@@ -3,5 +3,6 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/modernXr/AndroidManifest.xml b/app/src/modernXr/AndroidManifest.xml
index 279d940ef9..afa30249d0 100644
--- a/app/src/modernXr/AndroidManifest.xml
+++ b/app/src/modernXr/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/test/java/app/gamenative/enums/OSTest.kt b/app/src/test/java/app/gamenative/enums/OSTest.kt
new file mode 100644
index 0000000000..f3b6aec7c8
--- /dev/null
+++ b/app/src/test/java/app/gamenative/enums/OSTest.kt
@@ -0,0 +1,58 @@
+package app.gamenative.enums
+
+import java.util.EnumSet
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class OSTest {
+
+ @Test
+ fun `from string parses android`() {
+ assertEquals(EnumSet.of(OS.android), OS.from("android"))
+ }
+
+ @Test
+ fun `from string parses a comma separated list including android`() {
+ assertEquals(EnumSet.of(OS.windows, OS.android), OS.from("windows,android"))
+ }
+
+ @Test
+ fun `from string falls back to none for an unknown value`() {
+ assertEquals(EnumSet.of(OS.none), OS.from("some_unknown_os"))
+ }
+
+ @Test
+ fun `from string returns none for a null or empty value`() {
+ assertEquals(EnumSet.of(OS.none), OS.from(null))
+ assertEquals(EnumSet.of(OS.none), OS.from(""))
+ }
+
+ // OS.from(Int) always contains `none` too, since none.code == 0 and any bitmask ANDed with 0
+ // is 0 — a pre-existing quirk unrelated to the `android` bit — so these check membership
+ // rather than exact set equality.
+ @Test
+ fun `code and from int round trip for android alone`() {
+ val roundTripped = OS.from(OS.code(EnumSet.of(OS.android)))
+ assertTrue(roundTripped.contains(OS.android))
+ assertFalse(roundTripped.contains(OS.windows))
+ assertFalse(roundTripped.contains(OS.macos))
+ assertFalse(roundTripped.contains(OS.linux))
+ }
+
+ @Test
+ fun `code and from int round trip for a combination including android`() {
+ val osses = EnumSet.of(OS.windows, OS.linux, OS.android)
+ val roundTripped = OS.from(OS.code(osses))
+ assertTrue(roundTripped.containsAll(osses))
+ assertFalse(roundTripped.contains(OS.macos))
+ }
+
+ @Test
+ fun `android has a distinct bit from the other OS values`() {
+ assertEquals(0x08, OS.android.code)
+ val others = listOf(OS.none, OS.windows, OS.macos, OS.linux)
+ others.forEach { assertEquals(0, it.code and OS.android.code) }
+ }
+}
diff --git a/app/src/test/java/app/gamenative/utils/SteamUtilsDepotLanguageTest.kt b/app/src/test/java/app/gamenative/utils/SteamUtilsDepotLanguageTest.kt
index dc6cd0a851..742a3754fb 100644
--- a/app/src/test/java/app/gamenative/utils/SteamUtilsDepotLanguageTest.kt
+++ b/app/src/test/java/app/gamenative/utils/SteamUtilsDepotLanguageTest.kt
@@ -48,7 +48,8 @@ class SteamUtilsDepotLanguageTest {
ownedDlc: Map? = null,
licensedDepotIds: Set? = null,
hasSteamUnlockedBranch: Boolean = false,
- ) = SteamUtils.effectiveDepotLanguage(depots, preferred, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch)
+ wantAndroid: Boolean = false,
+ ) = SteamUtils.effectiveDepotLanguage(depots, preferred, ownedDlc, licensedDepotIds, hasSteamUnlockedBranch, wantAndroid)
// -- Priority 1: requested language wins when available --
@@ -195,4 +196,54 @@ class SteamUtilsDepotLanguageTest {
fun `returns preferred language when there are no depots`() {
assertEquals("french", resolve(emptyMap(), "french"))
}
+
+ // -- Android depot selection (wantAndroid) --
+
+ @Test
+ fun `returns requested language when the android depot ships it`() {
+ val depots = depotsOf(
+ depot(depotId = 1, language = "english", osList = EnumSet.of(OS.android)),
+ depot(depotId = 2, language = "french", osList = EnumSet.of(OS.android)),
+ )
+ assertEquals("french", resolve(depots, "french", wantAndroid = true))
+ }
+
+ @Test
+ fun `falls back to english for android depots when requested language is absent`() {
+ val depots = depotsOf(
+ depot(depotId = 1, language = "english", osList = EnumSet.of(OS.android)),
+ depot(depotId = 2, language = "german", osList = EnumSet.of(OS.android)),
+ )
+ assertEquals("english", resolve(depots, "french", wantAndroid = true))
+ }
+
+ @Test
+ fun `an untagged depot is not a neutral fallback for android — windows-only neutrality does not carry over`() {
+ val depots = depotsOf(
+ depot(depotId = 1, language = "german", osList = EnumSet.of(OS.android)),
+ // Untagged (OS.none): counts as installable for Windows (isWindowsCompatible), but
+ // isAndroidCompatible requires an explicit android tag, so this must NOT satisfy the
+ // Android pass and must NOT be treated as a neutral depot there.
+ depot(depotId = 2, language = "", osList = EnumSet.of(OS.none)),
+ )
+ assertEquals("german", resolve(depots, "french", wantAndroid = true))
+ }
+
+ @Test
+ fun `a windows-only depot is ignored when android is requested`() {
+ val depots = depotsOf(
+ depot(depotId = 1, language = "german", osList = EnumSet.of(OS.windows)),
+ )
+ // No android-compatible depot at all -> nothing to steer the language away from preferred.
+ assertEquals("french", resolve(depots, "french", wantAndroid = true))
+ }
+
+ @Test
+ fun `a depot explicitly tagged both android and neutral-language keeps the preferred language`() {
+ val depots = depotsOf(
+ depot(depotId = 1, language = "", osList = EnumSet.of(OS.android)),
+ depot(depotId = 2, language = "german", osList = EnumSet.of(OS.android)),
+ )
+ assertEquals("french", resolve(depots, "french", wantAndroid = true))
+ }
}