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)) + } }