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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.patch whitespace=-space-before-tab
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ resign.keystore

/app/modernXr/

# Robolectric/debug container snapshots written relative to the app module.
/app/.container

# Steam bootstrap shim source — withheld out of respect for Valve, since it
# encodes internal details of Steam's proprietary client that Valve does not
# publish. See THIRD_PARTY_NOTICES ("Steam Client Bootstrap Shim").
Expand Down
34 changes: 34 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Project agent instructions

## Remote Quest ADB through Tailminal

- A Quest 3 is attached by ADB to the tailnet host `lily`. Local `adb devices`
may be empty; check the remote ADB server before concluding that the headset
is unavailable.
- Tailminal is already installed on this workstation and running on `lily`.
It connects directly over Tailscale. Its operating guide is
<https://tailminal.justfeli.dev/llms.txt>.
- From PowerShell on this workstation, invoke the npm CMD launcher explicitly.
The `.ps1` wrapper has mishandled the `--` command boundary here:

```powershell
& "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb devices -l"
```

- Pass the entire remote command after `--` as one quoted argument. Prefer
one-shot `exec` calls with a finite `--timeout-ms` for unattended work:

```powershell
& "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb shell getprop ro.product.model"
& "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb shell tail -n 300 /sdcard/Android/data/app.gamenative/files/gamenativevr/launch.log"
& "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb logcat -d -v threadtime -t 1000"
```

- `tailminal hosts` depends on a local Tailminal server and may return no peers
when that server is stopped. Directly addressing the MagicDNS host `lily`
still works. If a direct command fails, check `tailscale status`, then retry
the Tailminal health/command path before treating the Quest as disconnected.
- Tailminal grants a full shell on the remote host. Keep commands scoped to the
requested task, prefer read-only diagnostics first, and do not install,
delete, reboot, or otherwise mutate the headset unless the user's request
authorizes it.
55 changes: 54 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ android {
manifestPlaceholders["screenOrientation"] = "unspecified"
buildConfigField("boolean", "XR_BUILD", "false")
buildConfigField("boolean", "MODERN_XR", "false")
buildConfigField("boolean", "ANDROID_XR", "false")

versionCode = 21
versionName = "1.2.0"
Expand Down Expand Up @@ -159,6 +160,19 @@ android {
buildConfigField("String", "PRODUCT_SKU", "\"$productSku\"")
manifestPlaceholders["screenOrientation"] = "landscape"
}
create("androidXr") {
dimension = "androidApi"
minSdk = 29
targetSdk = 36
ndk.abiFilters += listOf("arm64-v8a")
buildConfigField("boolean", "MODERN_ANDROID", "true")
buildConfigField("String", "PRELOAD_BIONIC_SO", "\"libredirect-bionic-wx.so\"")
buildConfigField("boolean", "XR_BUILD", "true")
buildConfigField("boolean", "MODERN_XR", "true")
buildConfigField("boolean", "ANDROID_XR", "true")
// Keep the launcher in Android XR Home Space. Only ImmersiveXrActivity is
// promoted to unmanaged Full Space by src/androidXr/AndroidManifest.xml.
}
}

buildTypes {
Expand Down Expand Up @@ -274,6 +288,17 @@ android {
setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr"))
}
}
getByName("androidXr") {
// The platform launch contract differs, but the OpenXR renderer, Wine runtime
// payload and launch-readiness implementation are shared with modernXr.
java.srcDir("src/modernXr/java")
assets {
srcDirs("src/modern/assets", "src/main/assets", "build/generated/xrPayload/modernXr")
}
jniLibs {
setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: On non-Windows builds, the androidXr APK omits libxrimmersive.so, so launching ImmersiveXrActivity fails with UnsatisfiedLinkError. Include src/modernXr/jniLibs in this source set or otherwise package the prebuilt library.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/build.gradle.kts, line 299:

<comment>On non-Windows builds, the `androidXr` APK omits `libxrimmersive.so`, so launching `ImmersiveXrActivity` fails with `UnsatisfiedLinkError`. Include `src/modernXr/jniLibs` in this source set or otherwise package the prebuilt library.</comment>

<file context>
@@ -274,6 +288,17 @@ android {
+                srcDirs("src/modern/assets", "src/main/assets", "build/generated/xrPayload/modernXr")
+            }
+            jniLibs {
+                setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr"))
+            }
+        }
</file context>
Suggested change
setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr"))
setSrcDirs(listOf("src/modern/jniLibs", "src/modernXr/jniLibs", "build/generated/xrNative/modernXr"))

}
}
getByName("debug") {
assets.srcDir(copyDebugManifest)
}
Expand Down Expand Up @@ -326,6 +351,32 @@ android {

tasks.register<Exec>("stageOpenComposite") {
enabled = hostCanRunXrPayloadScripts
val stageScript = rootProject.file("tools/stage-opencomposite.ps1")
val toolchainFingerprint = providers.exec {
commandLine(
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
stageScript.absolutePath,
"-PrintToolchainFingerprint",
)
}.standardOutput.asText.map { it.trim() }
inputs.files(
stageScript,
rootProject.file("tools/patches/opencomposite-gamenative-wine.patch"),
rootProject.file("tools/patches/opencomposite-gamenative-native-backend.patch"),
rootProject.file("tools/opencomposite-vulkan-x64.def"),
rootProject.file("app/src/main/windows/openvr_client/DrvGameNative.cpp"),
rootProject.file("app/src/main/windows/openvr_client/DrvGameNative.h"),
rootProject.file("app/src/main/windows/openxr_runtime/gamenative_control.c"),
rootProject.file("app/src/main/windows/openxr_runtime/gamenative_control.h"),
rootProject.file("app/src/main/windows/openxr_runtime/gamenative_openxr_unix.h"),
)
inputs.property("openCompositeCommit", "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d")
inputs.property("openCompositeBuildRevision", 4)
inputs.property("openCompositeToolchainFingerprint", toolchainFingerprint)
outputs.file(layout.buildDirectory.file("generated/xrPayload/modernXr/opencomposite_x64.dll"))
commandLine(
"powershell",
"-ExecutionPolicy",
Expand All @@ -352,7 +403,8 @@ android {
}

tasks.matching {
it.name.startsWith("mergeModernXr") && it.name.endsWith("Assets")
(it.name.startsWith("mergeModernXr") || it.name.startsWith("mergeAndroidXr")) &&
it.name.endsWith("Assets")
}.configureEach {
dependsOn("prepareModernXrPayload")
}
Expand Down Expand Up @@ -443,6 +495,7 @@ dependencies {
// Official Khronos OpenXR loader (Apache-2.0) for the Meta Quest immersive launch mode's
// native module (app/src/main/cpp/xrimmersive) — not a Winlator/GameNativeXR dependency.
"modernXrImplementation"("org.khronos.openxr:openxr_loader_for_android:1.1.61")
"androidXrImplementation"("org.khronos.openxr:openxr_loader_for_android:1.1.61")

// Winlator
implementation(libs.bundles.winlator)
Expand Down
53 changes: 53 additions & 0 deletions app/src/androidXr/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" />
<uses-permission android:name="android.permission.READ_LOGS" tools:node="remove" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" tools:node="remove" />
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />

<!-- Android XR dedicated-track/runtime capability. The launcher itself remains a
conventional Home Space activity; only the activity below owns an OpenXR session. -->
<uses-feature
android:name="android.software.xr.api.openxr"
android:required="true" />
<uses-feature
android:name="android.hardware.xr.input.controller"
android:required="false" />

<application>
<!-- Required for Android XR's brokered OpenXR runtime discovery. The Khronos loader
remains packaged by the AAR and dynamically connects to this system library. -->
<uses-native-library
android:name="libopenxr.google.so"
android:required="false" />

<!-- Moving between a Home Space panel and an unmanaged Full Space display changes
several display configuration fields. Handle those in place so the launcher does
not get recreated in the middle of its handoff to ImmersiveXrActivity. -->
<activity
android:name="app.gamenative.MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboard|keyboardHidden|navigation|uiMode|colorMode"
tools:replace="android:configChanges" />

<activity
android:name="app.gamenative.ui.screen.xr.ImmersiveXrActivity"
android:exported="false"
android:launchMode="singleTask"
android:resizeableActivity="false"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboard|keyboardHidden|navigation|uiMode|colorMode"
android:theme="@style/Theme.Pluvia">
<!-- Current Android XR/OpenXR spelling. This is activity-scoped so MainActivity
retains the platform default (Home Space) launch mode. -->
<property
android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_UNMANAGED" />
</activity>
</application>

</manifest>
15 changes: 10 additions & 5 deletions app/src/main/cpp/xrimmersive/jni_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Java_app_gamenative_ui_screen_xr_XrNative_nativeWaitWindowsFrame(
JNIEnv *env, jclass, jlong handlePtr, jlong afterSerial, jint timeoutMs,
jlongArray outTiming, jfloatArray outViews, jfloatArray outInput, jintArray outFlags) {
if (env->GetArrayLength(outTiming) < 11 || env->GetArrayLength(outViews) < 22 ||
env->GetArrayLength(outInput) < 36 || env->GetArrayLength(outFlags) < 3) return JNI_FALSE;
env->GetArrayLength(outInput) < 36 || env->GetArrayLength(outFlags) < 4) return JNI_FALSE;
xrimmersive::WindowsRuntimeSnapshot snapshot;
{
std::lock_guard<std::mutex> lock(gHandleMutex);
Expand Down Expand Up @@ -200,13 +200,18 @@ Java_app_gamenative_ui_screen_xr_XrNative_nativeWaitWindowsFrame(
inputValues[base + 17] = aim.position.z;
}
env->SetFloatArrayRegion(outInput, 0, 36, inputValues);
const jint flags[3] = {
const bool leftActive = input.aimPoseValid[0] || input.gripPoseValid[0];
const bool rightActive = input.aimPoseValid[1] || input.gripPoseValid[1];
const jint flags[4] = {
static_cast<jint>(snapshot.viewStateFlags),
static_cast<jint>(input.buttons),
static_cast<jint>((input.aimPoseValid[0] && input.gripPoseValid[0] ? 1 : 0) |
(input.aimPoseValid[1] && input.gripPoseValid[1] ? 2 : 0)),
static_cast<jint>((leftActive ? 1 : 0) | (rightActive ? 2 : 0)),
static_cast<jint>((input.gripPoseValid[0] ? 1 : 0) |
(input.gripPoseValid[1] ? 2 : 0) |
(input.aimPoseValid[0] ? 4 : 0) |
(input.aimPoseValid[1] ? 8 : 0)),
};
env->SetIntArrayRegion(outFlags, 0, 3, flags);
env->SetIntArrayRegion(outFlags, 0, 4, flags);
return JNI_TRUE;
}

Expand Down
24 changes: 22 additions & 2 deletions app/src/main/java/app/gamenative/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import app.gamenative.mods.NexusDownloadLinkInbox
import app.gamenative.mods.NexusIntegrationStatus
import app.gamenative.mods.NexusPendingDownloadStore
import app.gamenative.ui.screen.library.appscreen.BaseAppScreen
import app.gamenative.ui.screen.xr.ImmersiveSessionOwnership
import app.gamenative.service.SteamService
import app.gamenative.service.gog.GOGService
import app.gamenative.service.epic.EpicService
Expand Down Expand Up @@ -77,6 +78,7 @@ class MainActivity : ComponentActivity() {

fun isHeadset(context: Context): Boolean =
context.packageManager.hasSystemFeature("android.hardware.vr.headtracking") ||
context.packageManager.hasSystemFeature("android.software.xr.api.openxr") ||
Build.MANUFACTURER.equals("Oculus", true) ||
Build.MANUFACTURER.equals("Meta", true) ||
Build.MANUFACTURER.equals("Pico", true)
Expand Down Expand Up @@ -205,9 +207,16 @@ class MainActivity : ComponentActivity() {
}

// stale keepAlive from a prior crash/swipe — no container is actually running
if (SteamService.keepAlive && PluviaApp.xEnvironment == null) {
if (SteamService.keepAlive && PluviaApp.xEnvironment == null &&
(!BuildConfig.ANDROID_XR || !ImmersiveSessionOwnership.isOwnedByImmersive())
) {
Timber.w("onCreate: clearing stale keepAlive — no container running")
PluviaApp.shutdownEnvironment()
} else if (BuildConfig.ANDROID_XR && SteamService.keepAlive && PluviaApp.xEnvironment == null) {
Timber.i(
"onCreate: preserving keepAlive during immersive handoff (%s)",
ImmersiveSessionOwnership.snapshot(),
)
}

// Apply immersive mode based on user preference
Expand Down Expand Up @@ -372,7 +381,12 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
// emit before super so Compose DisposableEffects (which unregister
// listeners during super.onDestroy's lifecycle transition) still fire
if (!isChangingConfigurations) {
val launcherShouldHandleDestruction = if (BuildConfig.ANDROID_XR) {
ImmersiveSessionOwnership.shouldLauncherHandleDestruction(isChangingConfigurations)
} else {
!isChangingConfigurations
}
if (launcherShouldHandleDestruction) {
PluviaApp.events.emit(AndroidEvent.ActivityDestroyed)

// if exit() didn't run (listener already unregistered, race, etc.)
Expand All @@ -381,6 +395,12 @@ class MainActivity : ComponentActivity() {
Timber.w("onDestroy: keepAlive still set after ActivityDestroyed — forcing cleanup")
PluviaApp.shutdownEnvironment()
}
} else {
Timber.i(
"onDestroy: launcher teardown skipped; changingConfig=%b immersive=%s",
isChangingConfigurations,
ImmersiveSessionOwnership.snapshot(),
)
}

super.onDestroy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ fun GraphicsTabContent(state: ContainerConfigState, default: Boolean = false) {
)
Text(text = "${config.xrRenderScale}%")
}
if (!default) {
SettingsSwitch(
colors = settingsTileColorsAlt(),
title = { Text(text = stringResource(R.string.xr_open_composite)) },
subtitle = { Text(text = stringResource(R.string.xr_open_composite_description)) },
state = config.openCompositeEnabled,
onCheckedChange = {
state.config.value = config.copy(openCompositeEnabled = it)
},
)
}
}
SettingsListDropdown(
colors = settingsTileColors(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package app.gamenative.ui.screen.xr

import android.content.Context
import androidx.lifecycle.ViewModel
import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService

/** Retains the Windows VR control server across Android activity configuration recreation. */
internal class ImmersiveRuntimeViewModel : ViewModel() {
private var windowsVrRuntimeService: WindowsVrRuntimeService? = null

fun windowsVrRuntimeService(context: Context): WindowsVrRuntimeService =
windowsVrRuntimeService ?: WindowsVrRuntimeService(context.applicationContext).also {
windowsVrRuntimeService = it
}

fun closeWindowsVrRuntimeService() {
windowsVrRuntimeService?.close()
windowsVrRuntimeService = null
}

override fun onCleared() {
closeWindowsVrRuntimeService()
}
}
Loading